diff --git a/.claude/rules/go-layout.md b/.claude/rules/go-layout.md index f31d3c0..c8a208d 100644 --- a/.claude/rules/go-layout.md +++ b/.claude/rules/go-layout.md @@ -20,20 +20,68 @@ api/ .proto sources — buf's module root (see buf.yaml). pkg/ first-class, third-party-consumable Go integration — the only thing a plugin author needs to import. / pkg/model/, pkg/tool/, pkg/context/, pkg/memory/, - pkg/frontend/, pkg/widget/, plus pkg/kernel/ for the - kernel-callback service (docs/specifications/kernel-callbacks.md) - *.go hand-written ergonomic SDK: the thin, idiomatic Go - surface most plugin authors actually consume + pkg/frontend/, pkg/widget/, pkg/slashcommand/, + pkg/hook/ (the cross-category HookSubscriberService), + plus pkg/kernel/ for the kernel-callback service + (docs/specifications/kernel-callbacks.md) + *.go hand-written plugin-author SDK: domain types and + the author-facing interface(s), converted to/from + the generated wire types at the package boundary + (see "The pkg/ vs internal/ boundary" below) proto/v1/*.pb.go buf-generated message + gRPC stubs. Never hand-edited — see proto.md and plugin-runtime.md. + plugin/ the shared plugin-subprocess serving layer every + category SDK builds on — handshake, multi-service + muxing, the lazy kernel-callback handle, error + helpers. No proto/ subtree of its own. + render/, config/, schema/, content/ shared, cross-category builder + packages (RenderTree nodes, ConfigSchema, + the tool/model JSON-Schema subset, ContentBlock) + that every category SDK composes rather than + reimplementing per category. docs/specifications/ protocol contracts (already exists, authoritative) ``` Nothing generated lives at the repo root. `pkg//` is deliberately split in two: the `proto/v1/` subtree is 100% derived (`buf generate` output), while the sibling `.go` files in `pkg//` are hand-written -and are where most plugin authors actually spend their time — a thin, -idiomatic wrapper over the generated stubs, not the stubs themselves. +and are where most plugin authors actually spend their time — the +plugin-author-facing SDK, not a pass-through to the generated stubs. See +"The pkg/ vs internal/ boundary" below for exactly what that SDK layer is +and isn't allowed to do. + +## The pkg/ vs internal/ boundary + +`pkg//`'s hand-written `.go` files (never `proto/v1/`) and +`internal/` sit on opposite sides of a deliberate asymmetry in how many Go +representations a wire message gets: + +- **`pkg//` MAY define its own domain types** — plain Go structs + and enums shaped for how a plugin author actually thinks about the + category (e.g. `tool.Call`, `tool.Result`, `model.Spec`), converted + to/from the generated `pkg//proto/v1` message at the package + boundary (conventionally in a `convert.go`). This is a deliberate + ergonomics choice for the third-party-facing SDK: an author writing a + plugin should not have to hand-assemble `structpb.Struct` literals or + navigate a `oneof` wrapper type to implement one RPC. The conversion + layer is the SDK's job, not the author's. +- **`internal/` MUST consume the same `pkg//proto/v1` generated + types the wire actually carries, directly** — never a second, parallel + internal type that gets translated to and from the generated one. The + kernel-side client stub (the interface/driver-pattern code described + above) imports `pkg//proto/v1` (and, where convenient, the + `pkg/` SDK wrapper) exactly as a third-party plugin author does + on the other end of the connection. There is exactly one Go + representation of each wire message on the kernel side — this is + unchanged and remains load-bearing for `internal/`. + +A `pkg/` domain type is real Go, not a wire type in disguise, but +it stays a *thin* wrapper in spirit: no business logic lives in `convert.go` +beyond validating the invariants the category's own spec states as MUST +(`internal/`'s domain logic — policy, plan/apply, cost — is not +duplicated here). If a `pkg/` type starts accumulating behavior +beyond "shape the RPC ergonomically and validate what the spec requires," +that behavior belongs in `internal/`, not the SDK. `cmd/` binaries MUST stay thin: parse config, construct dependencies via `internal/` constructors, call `Run`. If a `cmd/` file grows past simple @@ -45,7 +93,7 @@ in a narrowly-named package that says what it does. ## Interfaces: the driver pattern -Every pluggable concern (each of the six provider categories, plus internal +Every pluggable concern (each of the seven provider categories, plus internal swappable backends like the memory store) follows the same shape: ``` @@ -77,15 +125,15 @@ vector — backend-agnostic by design, see `docs/specifications/memory/README.md is `internal/memory/` (interface) with `internal/memory/drivers/{markdown,sqlite,vector}/`. -This applies to internal swappable components. The six *plugin* categories -themselves (model, tool, context, memory, frontend, widget) are out-of-process -via `hashicorp/go-plugin` — see `plugin-runtime.md` — but the kernel-side code +This applies to internal swappable components. The seven *plugin* categories +themselves (model, tool, context, memory, frontend, widget, slashcommand) are +out-of-process via `hashicorp/go-plugin` — see `plugin-runtime.md` — but the kernel-side code that talks to them (the client stub, the registry, the cache) still follows this same interface/driver shape internally. The kernel-side client stub imports the same `pkg//proto/v1` generated types (and, where convenient, the `pkg/` SDK wrapper) -that a third-party plugin author imports on the other end of the connection. -There is exactly one Go representation of each wire message — the kernel -does not maintain a second, parallel internal type that gets translated to -and from the generated one. +that a third-party plugin author imports on the other end of the connection +— see "The pkg/ vs internal/ boundary" above for the full rule and the one +place a second Go representation *is* allowed (the plugin-author-facing +domain types inside `pkg/` itself, never `internal/`). diff --git a/.claude/rules/grpc.md b/.claude/rules/grpc.md index 790c5da..0d84d1f 100644 --- a/.claude/rules/grpc.md +++ b/.claude/rules/grpc.md @@ -19,12 +19,16 @@ dictated by the specs and MUST match exactly. | Tool | `Invoke` | server-streaming | `docs/specifications/tool/protocol.md` | | Frontend | `Attach` | **bidirectional** streaming | `docs/specifications/frontend/frontend-protocol.md` | | Widget | `Attach` | server-streaming only | `docs/specifications/frontend/widget-protocol.md` | +| Slashcommand | `Invoke` | server-streaming | `docs/specifications/slashcommand/protocol.md` (same shape as Tool's `Invoke` — a direct-invoke command is a tool-shaped operation) | | Kernel callback | `RunSession`, `CountTokens` | bidirectional (go-plugin's native plugin→kernel channel) | `docs/specifications/kernel-callbacks.md` | +| Kernel callback | `Subscribe` | server-streaming | `docs/specifications/kernel-callbacks.md#subscribe` (event-bus fan-out; see `docs/specifications/event-bus.md`) | +| Kernel callback | `ReadEvents` | server-streaming | `docs/specifications/kernel-callbacks.md#readevents` | Frontend `Attach` and the kernel-callback channel are the **only** two -genuinely bidirectional RPCs in the whole protocol. Do not default a new RPC -to bidi streaming because it "might need it later" — pick the narrowest shape -the spec calls for. +genuinely bidirectional RPCs in the whole protocol — that channel's own +`Subscribe`/`ReadEvents` additions are server-streaming, not a second bidi +RPC on it. Do not default a new RPC to bidi streaming because it "might need +it later" — pick the narrowest shape the spec calls for. - **A backend that has no real streaming to do still implements the streaming RPC shape** and emits exactly one terminal message. Do not add a @@ -75,15 +79,26 @@ kernel decide. cancellation promptly (previous section) is what makes that deadline actually bound wall-clock time instead of leaking a goroutine. -## The strong-typing rule and its one carve-out - -`proto.md` bans `Any`/untyped `bytes`/loose maps as a general rule. The -Emit→Render→Paint payload (`docs/specifications/model/protocol.md#render`, -`docs/specifications/frontend/render-tree.md`) is the one deliberate exception: it is -opaque *by design* so a producer's payload format can evolve independently -of the kernel. Do not "fix" this by giving it a concrete message type — that -would defeat the plugin-independence the specs are built around. Every other -field stays strongly typed. +## The strong-typing rule and its carve-outs + +`proto.md` bans `Any`/untyped `bytes`/loose maps as a general rule, with two +named exceptions — no others exist, and a third is not a shortcut to reach +for by analogy: + +- The Emit→Render→Paint payload (`docs/specifications/model/protocol.md#render`, + `docs/specifications/frontend/render-tree.md`). Opaque *by design* so a + producer's payload format can evolve independently of the kernel. Do not + "fix" this by giving it a concrete message type — that would defeat the + plugin-independence the specs are built around. +- The event-bus `Publish`/`BusEvent` payload (`docs/specifications/event-bus.md`, + `docs/specifications/kernel-callbacks.md#publish`). Opaque for a different + reason: a third-party plugin's own event shape is unknowable to the kernel + ahead of time, the same reasoning that already applies to a category + provider's `Configure`-time config values. `payload_type` and + `schema_version` carry enough typing information for a *subscriber* to + decode it; the kernel itself never needs to. + +Every other field stays strongly typed. Similarly, `docs/specifications/configuration/`'s two type systems — HCL/`cty` for provider config, a restricted JSON-Schema subset for tool I/O — are diff --git a/.claude/rules/logging-telemetry.md b/.claude/rules/logging-telemetry.md index 2122a34..36350e4 100644 --- a/.claude/rules/logging-telemetry.md +++ b/.claude/rules/logging-telemetry.md @@ -106,7 +106,15 @@ external systems. If yes, it's pure domain, full stop. `internal/telemetry.Provider`'s `ClientHandler()`/`ServerHandler()` into its `grpc.WithStatsHandler`/`grpc.StatsHandler` options (`internal/telemetry/grpchooks.go`). MUST NOT hand-roll a separate - trace-context propagation mechanism. + trace-context propagation mechanism. **This is unaffected by + `ExportSpans`/`RecordMetrics` relaying a plugin's finished spans/metrics + through the kernel** (`docs/specifications/observability.md#the-relay-model`) + — relay is a transport decision about where already-finished telemetry + data is *exported to*, not a second, competing mechanism for how an + in-flight call's trace context crosses the plugin boundary in the first + place. `traceparent` still propagates via the otelgrpc stats handlers + exactly as before; don't read the relay RPCs as license to also hand-roll + context propagation. - Replay-path code (`docs/specifications/state-backend.md`) MUST select the `noop` telemetry driver, unconditionally, no exceptions. Telemetry MUST NOT persist `trace_id`/`span_id` into any table and MUST NOT recompute a diff --git a/.claude/rules/plugin-runtime.md b/.claude/rules/plugin-runtime.md index 16df554..80c6469 100644 --- a/.claude/rules/plugin-runtime.md +++ b/.claude/rules/plugin-runtime.md @@ -5,7 +5,7 @@ paths: # Plugin runtime (`hashicorp/go-plugin`) conventions -Every one of the six plugin categories is a subprocess speaking gRPC over +Every one of the seven plugin categories is a subprocess speaking gRPC over `hashicorp/go-plugin`. This file covers the lifecycle/runtime half; `grpc.md` covers RPC shape and `proto.md` covers wire typing. diff --git a/.claude/rules/proto-layout.md b/.claude/rules/proto-layout.md new file mode 100644 index 0000000..ea94e40 --- /dev/null +++ b/.claude/rules/proto-layout.md @@ -0,0 +1,55 @@ +--- +paths: + - "**/*.proto" +--- + +# Protobuf file layout + +`proto.md` governs syntax, typing, documentation, and versioning for what goes inside a proto file; this rule governs how a package's declarations are split across files within `api/pluggableharness//v1/`. It exists because `buf.yaml` sets `breaking: use: FILE` (see `proto.md`'s versioning section) — file layout becomes part of the frozen wire contract the moment the first `v*` tag is cut, so the split described here MUST be mechanical and repeatable rather than ad hoc, the same way `go-layout.md` fixes Go package shape before any Go file exists to match a glob against. + +## The slot template + +Every package directory uses a fixed set of role-named files, never the package-leaf basename: + +| File | Holds | Present when | +|---|---|---| +| `service.proto` | The `service` block, nothing else | The package declares a service | +| `rpc_request.proto` | Every message in an rpc's input position, including empty ones (`message DescribeRequest {}`) | The package declares a service | +| `rpc_response.proto` | Every message in an rpc's **unary** return position | The service has at least one unary rpc | +| `events.proto` | Occurrence-shaped messages: anything flowing over a `stream` in either direction, plus oneof event envelopes and their variant payloads | The package has streamed rpcs or a standalone event/payload registry | +| `types.proto` | Domain messages and enums — capabilities, specs, records, refs, taxonomy enums | Almost every package | +| `errors.proto` | The category's `*Error` message and its `*ErrorCategory` enum | The package defines an error taxonomy | + +A slot with nothing to hold is not created. A package with no service (e.g. `common`, `render`, `content`) collapses to a single `types.proto` — except a package whose entire purpose is a flat event/payload registry (e.g. `event.v1`), which uses `events.proto` instead of `types.proto` as its one file. + +## Assignment is by role, never by name suffix + +A message's slot is determined by where it appears on the wire, not by whether its name ends in `Request`/`Response`/`Event`. `pluggableharness.context.v1`'s `Contribute(ContextRequest) returns (ContextContribution)` puts `ContextRequest` in `rpc_request.proto` and `ContextContribution` in `rpc_response.proto` even though neither name carries the expected suffix. A message nested inside a response but never itself returned by an rpc (e.g. a per-item result embedded in a list response) belongs in `types.proto`, not `rpc_response.proto`. + +A streamed return goes to `events.proto`, never `rpc_response.proto` — a server-streaming or bidirectional rpc's message flow is occurrence-shaped, not request/response-shaped. This applies uniformly to `stream` on either side of an rpc signature, per `grpc.md`'s streaming-shape table. + +## Nested types and grouped declarations travel together + +Nested messages, nested enums, and `reserved` statements always move with the message that owns them — they are never separated into a different file than their parent. A `oneof` wrapper and every one of its variant messages stay in the same file as a unit, even when that unit is large; splitting a oneof's variants away from its wrapper (or from each other) is never a valid cut, regardless of resulting file size. + +## Exactly one package doc comment per package + +`protoc-gen-go` copies the file-level comment block immediately above a `package` statement verbatim into the generated `.pb.go`'s package doc. With multiple files per proto package, only one file may carry that block, or multiple generated Go files end up claiming to be the package doc. The doc-comment file is `service.proto` for a service-bearing package, and the package's single collapsed file (`types.proto` or `events.proto`) for a service-free package — the proto analogue of a Go package's `doc.go`. + +Every other file in the package gets a one-line purpose comment placed below its own `option go_package` line, as a comment detached from the `package` statement, so it never attaches to the package doc. + +## Intra-package imports form a DAG + +protoc requires an explicit `import` for any type referenced from another file, including a sibling file in the same package — there is no implicit same-package visibility as in Go. The intra-package import graph MUST stay acyclic; `buf build` rejects a cycle outright, the same hazard `proto.md` and `docs/specifications/model/data-types.md` already document at the cross-package level. Allowed direction, no back-edges: + +``` +types.proto ─┬─> errors.proto ─┬─> rpc_response.proto ─┐ + └─────────────────┴─> events.proto ───────┼─> service.proto + └─> rpc_request.proto ─┘ +``` + +Each file imports only the specific sibling and cross-package files whose types it actually references — never a broader import for convenience. Cross-package imports name a specific file (e.g. `import "pluggableharness/schema/v1/types.proto";`), never a package as a whole. The existing import-block ordering convention holds per file: the `google/protobuf/*` well-known-types group first, then a single alphabetized `pluggableharness/*` group. + +## The layout is frozen at the first release tag + +Once `breaking: use: FILE` starts being enforced against a real `v*` tag, a declaration may be added to an existing slot file or to a brand-new file, but an existing declaration may never move from one file to another — that is a file-level break under `FILE` even when the wire format and generated Go API are unchanged. Do not "clean up" a slot file's contents after the first release; if a slot has grown unwieldy, that is a `v2` package decision, not a same-version file reshuffle. diff --git a/.claude/rules/proto.md b/.claude/rules/proto.md index a87a175..1279f24 100644 --- a/.claude/rules/proto.md +++ b/.claude/rules/proto.md @@ -12,7 +12,7 @@ is derived and never hand-edited (see `plugin-runtime.md`). ## Syntax and packaging - `syntax = "proto3";` always. -- Package per category, per version: `package pluggableharness..v1;` — `pluggableharness.model.v1`, `pluggableharness.tool.v1`, `pluggableharness.memory.v1`, `pluggableharness.context.v1`, `pluggableharness.frontend.v1`, `pluggableharness.widget.v1`, `pluggableharness.kernel.v1` (the kernel-callback service). File path mirrors the package under buf's module root (`buf.yaml`'s `api` module): `api/pluggableharness//v1/.proto`. +- Package per category, per version: `package pluggableharness..v1;` — `pluggableharness.model.v1`, `pluggableharness.tool.v1`, `pluggableharness.memory.v1`, `pluggableharness.context.v1`, `pluggableharness.frontend.v1`, `pluggableharness.widget.v1`, `pluggableharness.slashcommand.v1`, `pluggableharness.kernel.v1` (the kernel-callback service). A package's files live under `api/pluggableharness//v1/`, buf's module root; how its declarations are split across files within that directory is `proto-layout.md`'s concern, not this file's. - `option go_package = "github.com/pluggableharness/agent/pkg//proto/v1;v1";` on every file — explicit, never inferred, **always** the full module-qualified path (`github.com/pluggableharness/agent/...`). This *is* required, not optional: `protoc-gen-go` embeds `go_package`'s path verbatim into every cross-file Go `import` statement it generates, so any package imported by another proto (which is every shared package in this repo) needs the real importable path or the generated code fails to compile. Getting this backwards — omitting the module prefix on the theory that `out: .` already means repo-root — was an actual bug caught during Wave B integration: it compiled fine for a standalone, never-imported file, then broke the moment a second file imported it, producing `import "pkg/config/proto/v1"` instead of `import "github.com/pluggableharness/agent/pkg/config/proto/v1"`. - `buf.gen.yaml`'s Go plugins run with `out: .` **and** `opt: module=github.com/pluggableharness/agent`. This `module` option is what reconciles the full-path `go_package` above with landing output at the intended repo-root-relative `pkg//proto/v1/` instead of a redundant nested `./github.com/pluggableharness/agent/pkg/.../` — it tells `protoc-gen-go` to keep the full path for generated import statements but strip that same prefix when computing where to *write* the file relative to `out`. This is why `api/`'s tree (which mirrors the full dotted package name, `api/pluggableharness/...`) and `pkg/`'s tree (which doesn't) intentionally look different — see `go-layout.md`. Do not drop the `module` opt from `buf.gen.yaml` to "simplify" it — that's the line that makes the split possible. @@ -26,15 +26,37 @@ strongly typed as the Go code that implements it. silently-valid-looking value. - No `google.protobuf.Any` for anything the spec can name a concrete type for. If a field's shape varies by category or plugin, model it as a - `oneof` of named messages, not `Any` or a `bytes` blob — with **one - explicit, spec-documented exception**: the emit/render payload itself. - `docs/specifications/model/protocol.md` and `docs/specifications/frontend/render-tree.md` - define the Emit→Render→Paint payload as deliberately opaque (kernel and other - plugins don't interpret it) — that field stays `bytes`, and only that field. + `oneof` of named messages, not `Any` or a `bytes` blob — with **two + explicit, spec-documented exceptions**, and no third to be added by + analogy without its own spec-level justification: + 1. The emit/render payload itself. + `docs/specifications/model/protocol.md` and `docs/specifications/frontend/render-tree.md` + define the Emit→Render→Paint payload as deliberately opaque (kernel and other + plugins don't interpret it) — that field stays `bytes`, and only that field. + 2. The event-bus publish payload (`kernel.v1.PublishRequest.payload` / + `BusEvent.payload`). `docs/specifications/event-bus.md` and + `docs/specifications/kernel-callbacks.md#publish` define it as opaque for + the same underlying reason as #1 — a third-party plugin's own event + shape can't be named by the kernel's proto ahead of time — carried + alongside `payload_type`/`schema_version` so a *subscriber* can decode + it even though the kernel itself never does. - No untyped `map` or `map` standing in for a structured payload. A `map` is acceptable only for genuine - open-ended key/value data (e.g. HTTP-style headers) — never as a substitute - for a message with named fields. + open-ended key/value data (e.g. HTTP-style headers, `metric.v1.MetricRecord.attributes`) + — never as a substitute for a message with named fields. +- **`google.protobuf.Struct` is the sanctioned way to carry a genuinely + dynamic, per-call-site attribute/value set that a fixed message shape + can't name in advance** — distinct from the `Any`/untyped-`bytes` ban + above, which targets a field standing in for a payload the spec *could* + name concretely but didn't. `Struct` is reached for only when the set of + keys is inherently open-ended by design, not merely inconvenient to + enumerate: `log.v1.LogEntry.fields` (mirrors `slog.Attr`'s open key/value + model), `config.v1`'s `ConfigureRequest.config` and `kernel.v1.GetConfigResult.config` + (already-decoded `agent.hcl` values, whose shape is the *provider's* schema, + not the kernel's to name), and `trace.v1.Span`/`SpanEvent`'s `attributes` + (an OTel span's attribute set, open-ended per call site by the same + reasoning as `log.v1.LogEntry.fields`) are the precedents. A field whose + keys are actually fixed and enumerable belongs in a real message instead. - Every field that has a natural bounded domain (status, kind, risk class, error category) is an `enum`, not a `string`. `docs/specifications/tool/conformance.md`'s `ToolErrorCategory`, and `docs/specifications/tool/data-types.md`'s `RiskClass` diff --git a/.github/instructions/proto.instructions.md b/.github/instructions/proto.instructions.md index dbacf88..780f79b 100644 --- a/.github/instructions/proto.instructions.md +++ b/.github/instructions/proto.instructions.md @@ -4,9 +4,10 @@ applyTo: "**/*.proto" # Protobuf conventions -The full rules live in `.claude/rules/proto.md` and `plugin-runtime.md`. +The full rules live in `.claude/rules/proto.md`, `.claude/rules/proto-layout.md`, and `plugin-runtime.md`. - `proto3` syntax; package `pluggableharness..v1`; `go_package` is fully module-qualified and matched by a `module=` opt in `buf.gen.yaml`. +- A package's declarations are split across role-named files (`service.proto`, `rpc_request.proto`, `rpc_response.proto`, `events.proto`, `types.proto`, `errors.proto`) per `.claude/rules/proto-layout.md`'s slot template — never one file per package leaf, and never a name-suffix guess at which slot a message belongs in. - Strong typing throughout: every enum has a `_UNSPECIFIED = 0` value; no `google.protobuf.Any` and no loose string maps — the opaque frontend render payload is the one deliberate carve-out; bounded domains are enums, identifiers are typed messages. - Every message, field, rpc, and enum carries a doc comment. - `buf lint` and `buf breaking` must pass. Wire-breaking changes never mutate `v1` in place — they ship as a new `vN` package, and removed field numbers are `reserved`. diff --git a/CLAUDE.md b/CLAUDE.md index 8190064..a3e6ec5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # PluggableHarness Agent -An AI coding harness built as a Go microkernel: the kernel owns plugin lifecycle, the plan/apply policy gate, config, and the session log — everything opinionated (models, tools, context, memory, frontends, widgets) is an out-of-process gRPC plugin. Repo: [github.com/pluggableharness/agent](https://github.com/pluggableharness/agent). This file orients a fresh session; it repeats nothing from global instructions or `.claude/rules/`. +An AI coding harness built as a Go microkernel: the kernel owns plugin lifecycle, the plan/apply policy gate, config, and the session log — everything opinionated (models, tools, context, memory, frontends, widgets, slash commands) is an out-of-process gRPC plugin. Repo: [github.com/pluggableharness/agent](https://github.com/pluggableharness/agent). This file orients a fresh session; it repeats nothing from global instructions or `.claude/rules/`. ## Source of truth @@ -10,7 +10,7 @@ An AI coding harness built as a Go microkernel: the kernel owns plugin lifecycle | Path | Contents | |---|---| -| `docs/specifications/` | The protocol contracts: one directory per plugin category (`model/`, `tool/`, `context/`, `memory/`, `frontend/` incl. widgets) plus kernel behavior (`agent-loop/`, `configuration/`, `kernel-callbacks.md`, `state-backend.md`) | +| `docs/specifications/` | The protocol contracts: one directory per plugin category (`model/`, `tool/`, `context/`, `memory/`, `frontend/` incl. widgets, `slashcommand/`) plus kernel behavior (`agent-loop/`, `configuration/`, `kernel-callbacks.md`, `state-backend.md`) | | `docs/first-party/` | Separate first-party catalog (tools, model providers). **Not** the tool *protocol* — that's `docs/specifications/tool/` | | `api/` | `.proto` sources, buf module root (`buf.yaml`, `buf.gen.yaml`) | | `internal/` | Kernel-side implementation — config, registry, policy, agentprofile, pluginruntime, kernelcallback, telemetry, log, hclsecret, producer | diff --git a/README.md b/README.md index e669a4d..be1538d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

The AI coding harness you never have to fork.

-

A real microkernel. Six plugin categories. One config file. Every opinion is swappable — and none of them are ours.

+

A real microkernel. Seven plugin categories. One config file. Every opinion is swappable — and none of them are ours.

Release @@ -70,7 +70,7 @@ The kernel resolves every provider the profile needs — pinned, checksummed, lo ## Why this wins -### 🧩 Six plugin categories. One shape. Learn it once. +### 🧩 Seven plugin categories. One shape. Learn it once. | Category | What it owns | |---|---| @@ -80,8 +80,9 @@ The kernel resolves every provider the profile needs — pinned, checksummed, lo | **Memory provider** | What persists across sessions — markdown, sqlite, vector, remote | | **Frontend provider** | How the session is painted — terminal, web, voice, all at once | | **Widget provider** | Persistent display state derived from the live event stream | +| **Slashcommand provider** | Direct-invoke commands — a tool-shaped operation in its own right | -Every category speaks the same protocol shape: declare what you do, accept your config, do your work. Master one, and you've mastered all six. +Every category speaks the same protocol shape: declare what you do, accept your config, do your work. Master one, and you've mastered all seven. ### 🔒 A plan before anything mutates. No plugin can route around it. diff --git a/api/pluggableharness/common/v1/common.proto b/api/pluggableharness/common/v1/types.proto similarity index 80% rename from api/pluggableharness/common/v1/common.proto rename to api/pluggableharness/common/v1/types.proto index 166c424..49624c4 100644 --- a/api/pluggableharness/common/v1/common.proto +++ b/api/pluggableharness/common/v1/types.proto @@ -28,7 +28,7 @@ option go_package = "github.com/pluggableharness/agent/pkg/common/proto/v1;commo // persist time (specifications/state-backend.md §3), and never appears on // the wire from a plugin — plugins never generate `sequence` values. -// Category identifies which of the six plugin categories a producer +// Category identifies which of the seven plugin categories a producer // implements. Used wherever a message needs to refer to "a producer of any // kind" generically — e.g. the kernel's producer registry and the // state-backend's producers table (specifications/state-backend.md §4). @@ -48,6 +48,8 @@ enum Category { CATEGORY_FRONTEND = 5; // A widget provider — specifications/frontend.md §4. CATEGORY_WIDGET = 6; + // A slashcommand provider — specifications/slashcommand/. + CATEGORY_SLASHCOMMAND = 7; } // HookPoint identifies one of the eight dispatchable points in the agent @@ -115,7 +117,7 @@ message ProducerRef { // used in agent.hcl's provider {} and required_providers {} blocks. string source = 3; - // Which of the six plugin categories this producer implements. + // Which of the seven plugin categories this producer implements. Category category = 4; // The go-plugin handshake protocol version this producer build was @@ -133,7 +135,7 @@ message ProducerRef { // for replay. Use ProducerRef when the exact build matters; use // ProviderRef when only the logical identity does. message ProviderRef { - // Which of the six plugin categories this reference names. + // Which of the seven plugin categories this reference names. Category category = 1; // The plugin's declared name, e.g. "anthropic". Unique within a category, @@ -160,3 +162,33 @@ message CallContext { // The session's working directory at call time. string working_directory = 3; } + +// PromptExpansionSpec declares a static template-expansion slash +// command: never executes anything, the kernel expands `template` with +// the user's arguments and submits the result as an ordinary +// user_message, costing a model turn. Declarable directly in any +// category's own capability response (model.md §2 Capabilities, tool.md +// §2 GetSchemaResponse, context.md §2 ContextCapabilities, memory.md §3 +// MemoryCapabilities, frontend.md §2 FrontendCapabilities). Lives here +// rather than in slashcommand.v1 specifically because it has zero +// dependency on that package's own vocabulary (kind/risk/concurrency, +// borrowed from tool.v1) — homing it in the import-nothing leaf avoids +// giving every embedding category's package an edge into slashcommand.v1 +// merely to declare a field that never invokes anything. A +// directly-invocable slash command is a different, tool-shaped thing — +// see pluggableharness.slashcommand.v1.SlashCommandSpec. +message PromptExpansionSpec { + // The command's name, without the leading "/". MUST be unique across + // every prompt-expansion command declared by every provider in the + // session — a name collision at config-load time is a hard error + // (configuration.md §5), independent of the direct-invoke namespace + // pluggableharness.slashcommand.v1.SlashCommandSpec.name occupies. + string name = 1; + + // Shown in the frontend's hotkey_hints region and wherever else the + // frontend surfaces available commands. + string description = 2; + + // The prompt template to expand, using "{arg}"-style placeholders. + string template = 3; +} diff --git a/api/pluggableharness/config/v1/config.proto b/api/pluggableharness/config/v1/types.proto similarity index 100% rename from api/pluggableharness/config/v1/config.proto rename to api/pluggableharness/config/v1/types.proto diff --git a/api/pluggableharness/content/v1/content.proto b/api/pluggableharness/content/v1/types.proto similarity index 100% rename from api/pluggableharness/content/v1/content.proto rename to api/pluggableharness/content/v1/types.proto diff --git a/api/pluggableharness/context/v1/context.proto b/api/pluggableharness/context/v1/context.proto deleted file mode 100644 index 93ad01d..0000000 --- a/api/pluggableharness/context/v1/context.proto +++ /dev/null @@ -1,298 +0,0 @@ -syntax = "proto3"; - -// Package pluggableharness.context.v1 defines the context provider plugin protocol -// described in specifications/context.md — plugins that hook -// context-assemble and contribute content to the prompt before each model -// call (e.g. a CLAUDE.md reader, an AGENTS.md reader, a git-status/file-tree -// summarizer). See .claude/rules/proto.md. -// -// ContextSection and Stability — the section chain this protocol assembles -// and the turn-to-turn-change hint each section carries — are defined in -// pluggableharness.content.v1, not here: that chain is consumed by -// both this protocol and the model provider's completion request -// (forthcoming), so it's homed alongside content.v1's other shared content -// shapes rather than duplicated or owned by only one consumer. -package pluggableharness.context.v1; - -import "google/protobuf/struct.proto"; -import "pluggableharness/common/v1/common.proto"; -import "pluggableharness/config/v1/config.proto"; -import "pluggableharness/content/v1/content.proto"; -import "pluggableharness/model/v1/model.proto"; -import "pluggableharness/render/v1/render.proto"; -import "pluggableharness/slashcommand/v1/slashcommand.proto"; - -option go_package = "github.com/pluggableharness/agent/pkg/context/proto/v1;contextv1"; - -// ContextService is the context provider plugin protocol described in -// specifications/context.md. A context provider plugin exposes -// GetCapabilities, Configure, and Contribute (context.md §1, all MUST); it -// MAY additionally implement Render (§9). -service ContextService { - // GetCapabilities reports this provider's static properties — its - // requested token budget, content stability, whether it acts as a - // compactor, and any slash commands or config schema it declares. - // context.md §2. - rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); - - // Configure delivers this provider's agent.hcl config block, already - // decoded via the schema-to-cty bridge. context.md §3. MUST reject with a - // structured error (surfaced as a ContextError in the gRPC status detail, - // per .claude/rules/grpc.md) if a declared source path/glob resolves to - // nothing on disk, rather than deferring to a silent-empty Contribute at - // first call. - rpc Configure(ConfigureRequest) returns (ConfigureResponse); - - // Contribute is the context-assemble RPC: the kernel invokes it at least - // once per turn, before each model call, and the provider returns the - // full accumulated ContextSection chain with its own section appended. - // context.md §1 and §4 (both MUST). Deliberately unary, not streamed — - // unlike a model provider's StreamCompletion, context assembly happens - // before the model call starts and convention-file/orientation content is - // small enough not to need token-level streaming. Contribute MUST return - // the full accumulated section chain (this provider's own section - // appended to prior_sections), never a delta. - // - // buf:lint:ignore RPC_REQUEST_STANDARD_NAME - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Request/response are the bare "ContextRequest"/"ContextContribution" - // per context.md §4's literal spec. Neither is reused by another RPC in - // this file (no uniqueness violation) — this is purely a naming-style - // preference, and the spec's own names carry real documentation value. - rpc Contribute(ContextRequest) returns (ContextContribution); - - // Render optionally renders this provider's contribution via the general - // Emit->Render->Paint pipeline, e.g. to display an injected CLAUDE.md - // section collapsed by default in a transcript view. context.md §9. MAY - // be implemented; if not, the kernel falls back to its generic default - // rendering. - rpc Render(RenderRequest) returns (RenderResponse); - - // Describe reports this plugin build's own identity — {name, version, - // source, category, protocol_version} via ProducerRef — independent of - // any lock-file entry. This is the mechanism a dev_overrides-resolved - // binary (which has no provider {} lock entry to read identity from) - // uses to self-report at connection time; see - // docs/specifications/configuration/lock-file.md's dev_overrides note, - // which is the canonical explanation for this RPC across every plugin - // category that gains it in this protocol revision. - rpc Describe(DescribeRequest) returns (DescribeResponse); -} - -// GetCapabilitiesRequest carries no fields — GetCapabilities takes no -// request-scoped parameters. -message GetCapabilitiesRequest {} - -// GetCapabilitiesResponse wraps ContextCapabilities for the RPC signature, -// per this repo's per-RPC envelope convention (.claude/rules/proto.md). -message GetCapabilitiesResponse { - ContextCapabilities capabilities = 1; -} - -// ConfigureRequest wraps the provider's agent.hcl config block, already -// decoded via the schema-to-cty bridge, for the Configure RPC. -message ConfigureRequest { - // The decoded config object. Field contents are provider-specific — which - // file(s)/globs to read, max-hop @import depth, whether to strip HTML - // comments, etc. (context.md §3). - google.protobuf.Struct config = 1; -} - -// ContextCapabilities reports a context provider's static properties. -// context.md §2. stability and compactor MUST be re-queryable cheaply and -// MUST NOT depend on a live read of the content source — a provider reading -// CLAUDE.md declares stability as a fixed property of the plugin, not a -// per-call judgment. -message ContextCapabilities { - // The token cap this provider requests if agent.hcl does not override it. - // MUST be set. context.md §2, §6. - int64 default_token_budget = 1; - - // Whether this provider's contributed content changes turn to turn. MUST - // be set. context.md §2, §7. - pluggableharness.content.v1.Stability stability = 2; - - // Whether this provider acts as a compactor: MAY rewrite, merge, or drop - // other providers' sections in the chain it receives, and MAY receive - // conversation_history on ContextRequest and return rewritten_history. - // MUST be set; defaults to false. context.md §5, §5.1. - bool compactor = 3; - - // Slash commands this provider contributes. MAY be empty. - // context.md §2, configuration.md §5. - repeated pluggableharness.slashcommand.v1.SlashCommandSpec slash_commands = 4; - - // This provider's agent.hcl config schema, advertised so the kernel knows - // what fields Configure accepts. configuration.md §4. - pluggableharness.config.v1.ConfigSchema config_schema = 5; - - // Which hook points (agent-loop/hook-dispatch.md) this provider declares - // HookSubscriberService.DispatchHook subscriptions for, advertised - // up front alongside its other static properties. MAY be empty — a - // context provider with no hook{} blocks in agent.hcl never has - // DispatchHook called on it. The enum itself lives in common.v1, not - // hook.v1 — hook.v1 imports this package's model/tool/plan dependencies, - // so a category capability message importing hook.v1 directly would - // cycle back through it; common.v1 is the shared leaf package instead. - repeated pluggableharness.common.v1.HookPoint supported_hook_points = 6; -} - -// ConfigureResponse is empty on success. A Configure failure (e.g. a -// declared source path/glob resolving to nothing) surfaces as a gRPC status -// carrying a ContextError in its structured detail, per -// .claude/rules/grpc.md — not as an in-band field on this message. -message ConfigureResponse {} - -// ContextRequest is the context-assemble RPC's request, delivered to -// Contribute once per firing. context.md §4. -message ContextRequest { - // The current session's identifier. - string session_id = 1; - - // The parent session's identifier, when this session is a sub-agent - // session. Empty for a top-level session. - string parent_session_id = 2; - - // Which turn of the session this firing is for. A ULID, standardized - // across the whole protocol (matches plan.v1's turn_id field). Same field - // number as the retired int64 turn-number predecessor field; the rename - // is a sanctioned pre-release wire change, not a v2 bump. - string turn_id = 3; - - // The kernel's computed token allocation for this provider on this call. - // MUST be set. A returned section MUST NOT exceed this. context.md §4, - // §6. - int64 token_budget = 4; - - // The model this contribution is being assembled for, so the provider can - // tailor content (and compute tokens against the right budget) for the - // model that will actually consume it. MUST be set. context.md §4. - pluggableharness.model.v1.ModelTarget model_target = 5; - - // Paths touched so far this session, enabling JIT-scoped contributions - // (e.g. a subdirectory-scoped convention-file reader). MAY be empty, e.g. - // at turn 0 / session start. context.md §4, §8. - repeated string files_touched = 6; - - // The session's current working directory. - string working_directory = 7; - - // The accumulated output of earlier providers in this hook's - // declaration-order chain. MUST be set (MAY be empty on the first - // provider in the chain). context.md §4, §5. - repeated pluggableharness.content.v1.ContextSection prior_sections = 8; - - // The session's conversation history. Populated ONLY for a provider whose - // ContextCapabilities.compactor == true; a non-compactor provider MUST - // NOT receive this — for those providers this field arrives empty, which - // is indistinguishable from (and semantically equivalent to) "not - // provided". context.md §5.1. - repeated pluggableharness.content.v1.Message conversation_history = 9; - - // The current conversation-history token total, kernel-computed. One of - // the two signals a compactor provider needs to decide WHEN to compact - // without re-counting history itself — see data-types.md's compactor - // workflow discussion in the orbit of §5.1. - int64 history_tokens = 10; - - // The total assembled context size of the previous turn (all providers' - // sections combined), kernel-computed. The second compact-timing signal, - // alongside history_tokens above. - int64 assembled_tokens_last_turn = 11; -} - -// ContextContribution is Contribute's response: the full, possibly-modified -// section chain, with this provider's own section appended — never a -// delta. context.md §4. -message ContextContribution { - // The full accumulated chain, in declaration order, including this - // provider's own new or updated section(s). - repeated pluggableharness.content.v1.ContextSection sections = 1; - - // The session's conversation history, rewritten to replace what was sent - // in ContextRequest.conversation_history. MAY be included by a compactor - // provider (ContextCapabilities.compactor == true) alongside its section - // contribution. When present, the kernel MUST replace the turn's - // conversation history with this before the next model call. context.md - // §5.1. - repeated pluggableharness.content.v1.Message rewritten_history = 2; -} - -// ContextErrorCategory classifies a context provider's failures. -// context.md §10 — smaller than the model-provider taxonomy -// (model.md §8), but a plugin MUST still classify failures rather than -// collapsing them into one generic error. -enum ContextErrorCategory { - // Zero value. Never valid for a real error; its presence on the wire - // means a caller forgot to set the field. - CONTEXT_ERROR_CATEGORY_UNSPECIFIED = 0; - // A declared file/glob/source was unreadable at call time (deleted - // mid-session, permission error). The kernel's expected reaction: drop - // the section for this turn, log; do not fail the turn. - CONTEXT_ERROR_CATEGORY_SOURCE_UNAVAILABLE = 1; - // The provider's own section (or, for a compactor, its whole returned - // chain) exceeds token_budget. The kernel's expected reaction: reject - // per context.md §6; do not fail the turn for a non-compactor violator. - CONTEXT_ERROR_CATEGORY_BUDGET_EXCEEDED = 2; - // A non-compactor provider mutated a section it doesn't own (context.md - // §5). The kernel's expected reaction: discard the entire response, - // restore the prior chain, log the violation. - CONTEXT_ERROR_CATEGORY_SCOPE_VIOLATION = 3; - // A malformed request — a kernel/adapter bug. MUST NOT be retried as-is; - // MUST be logged with the full request shape. - CONTEXT_ERROR_CATEGORY_INVALID_REQUEST = 4; - // Any other failure. MUST include the raw plugin error message for - // debugging. - CONTEXT_ERROR_CATEGORY_UNKNOWN = 5; -} - -// ContextError is the structured error detail a context provider attaches -// to a failed RPC's gRPC status, per .claude/rules/grpc.md's error-taxonomy -// convention. -message ContextError { - // Which category of failure this is. - ContextErrorCategory category = 1; - - // Human-readable error detail, e.g. the raw plugin error message for - // CONTEXT_ERROR_CATEGORY_UNKNOWN. - string message = 2; - - // Whether the kernel may retry the call that produced this error. - bool retryable = 3; -} - -// RenderRequest carries the opaque payload for the optional Render RPC. -message RenderRequest { - // The opaque emitted payload to render — see .claude/rules/grpc.md's - // Emit->Render->Paint carve-out. Never interpreted by the kernel or any - // other plugin; only the producing provider's own Render implementation - // understands its shape. - bytes payload = 1; - - // The schema version this payload was emitted against, so a Render - // implementation can detect drift between the version it was built - // against and the version live in a running session. See - // ../frontend/render-tree.md#schema-versioning for the canonical - // definition of this field's semantics (owned by the frontend/widget - // workstream; this field just carries the same value). - string schema_version = 2; -} - -// RenderResponse wraps the rendered output of the optional Render RPC. -message RenderResponse { - // The rendered tree, per the general Emit->Render->Paint pipeline - // (frontend.md §1). context.md §9. - pluggableharness.render.v1.RenderTree tree = 1; -} - -// DescribeRequest carries no fields — Describe takes no request-scoped -// parameters. -message DescribeRequest {} - -// DescribeResponse reports this plugin build's own identity. See the -// Describe RPC comment on ContextService above. -message DescribeResponse { - // This plugin build's identity: name, version, source, category, - // protocol_version. - pluggableharness.common.v1.ProducerRef producer = 1; -} diff --git a/api/pluggableharness/context/v1/errors.proto b/api/pluggableharness/context/v1/errors.proto new file mode 100644 index 0000000..1ae7b8f --- /dev/null +++ b/api/pluggableharness/context/v1/errors.proto @@ -0,0 +1,50 @@ +syntax = "proto3"; + +package pluggableharness.context.v1; + +option go_package = "github.com/pluggableharness/agent/pkg/context/proto/v1;contextv1"; + +// The context provider protocol's error taxonomy. + +// ContextErrorCategory classifies a context provider's failures. +// context.md §10 — smaller than the model-provider taxonomy +// (model.md §8), but a plugin MUST still classify failures rather than +// collapsing them into one generic error. +enum ContextErrorCategory { + // Zero value. Never valid for a real error; its presence on the wire + // means a caller forgot to set the field. + CONTEXT_ERROR_CATEGORY_UNSPECIFIED = 0; + // A declared file/glob/source was unreadable at call time (deleted + // mid-session, permission error). The kernel's expected reaction: drop + // the section for this turn, log; do not fail the turn. + CONTEXT_ERROR_CATEGORY_SOURCE_UNAVAILABLE = 1; + // The provider's own section (or, for a compactor, its whole returned + // chain) exceeds token_budget. The kernel's expected reaction: reject + // per context.md §6; do not fail the turn for a non-compactor violator. + CONTEXT_ERROR_CATEGORY_BUDGET_EXCEEDED = 2; + // A non-compactor provider mutated a section it doesn't own (context.md + // §5). The kernel's expected reaction: discard the entire response, + // restore the prior chain, log the violation. + CONTEXT_ERROR_CATEGORY_SCOPE_VIOLATION = 3; + // A malformed request — a kernel/adapter bug. MUST NOT be retried as-is; + // MUST be logged with the full request shape. + CONTEXT_ERROR_CATEGORY_INVALID_REQUEST = 4; + // Any other failure. MUST include the raw plugin error message for + // debugging. + CONTEXT_ERROR_CATEGORY_UNKNOWN = 5; +} + +// ContextError is the structured error detail a context provider attaches +// to a failed RPC's gRPC status, per .claude/rules/grpc.md's error-taxonomy +// convention. +message ContextError { + // Which category of failure this is. + ContextErrorCategory category = 1; + + // Human-readable error detail, e.g. the raw plugin error message for + // CONTEXT_ERROR_CATEGORY_UNKNOWN. + string message = 2; + + // Whether the kernel may retry the call that produced this error. + bool retryable = 3; +} diff --git a/api/pluggableharness/context/v1/rpc_request.proto b/api/pluggableharness/context/v1/rpc_request.proto new file mode 100644 index 0000000..c748980 --- /dev/null +++ b/api/pluggableharness/context/v1/rpc_request.proto @@ -0,0 +1,103 @@ +syntax = "proto3"; + +package pluggableharness.context.v1; + +import "google/protobuf/struct.proto"; +import "pluggableharness/content/v1/types.proto"; +import "pluggableharness/model/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/context/proto/v1;contextv1"; + +// Request messages for the context provider protocol's RPCs. + +// GetCapabilitiesRequest carries no fields — GetCapabilities takes no +// request-scoped parameters. +message GetCapabilitiesRequest {} + +// ConfigureRequest wraps the provider's agent.hcl config block, already +// decoded via the schema-to-cty bridge, for the Configure RPC. +message ConfigureRequest { + // The decoded config object. Field contents are provider-specific — which + // file(s)/globs to read, max-hop @import depth, whether to strip HTML + // comments, etc. (context.md §3). + google.protobuf.Struct config = 1; +} + +// ContextRequest is the context-assemble RPC's request, delivered to +// Contribute once per firing. context.md §4. +message ContextRequest { + // The current session's identifier. + string session_id = 1; + + // The parent session's identifier, when this session is a sub-agent + // session. Empty for a top-level session. + string parent_session_id = 2; + + // Which turn of the session this firing is for. A ULID, standardized + // across the whole protocol (matches plan.v1's turn_id field). Same field + // number as the retired int64 turn-number predecessor field; the rename + // is a sanctioned pre-release wire change, not a v2 bump. + string turn_id = 3; + + // The kernel's computed token allocation for this provider on this call. + // MUST be set. A returned section MUST NOT exceed this. context.md §4, + // §6. + int64 token_budget = 4; + + // The model this contribution is being assembled for, so the provider can + // tailor content (and compute tokens against the right budget) for the + // model that will actually consume it. MUST be set. context.md §4. + pluggableharness.model.v1.ModelTarget model_target = 5; + + // Paths touched so far this session, enabling JIT-scoped contributions + // (e.g. a subdirectory-scoped convention-file reader). MAY be empty, e.g. + // at turn 0 / session start. context.md §4, §8. + repeated string files_touched = 6; + + // The session's current working directory. + string working_directory = 7; + + // The accumulated output of earlier providers in this hook's + // declaration-order chain. MUST be set (MAY be empty on the first + // provider in the chain). context.md §4, §5. + repeated pluggableharness.content.v1.ContextSection prior_sections = 8; + + // The session's conversation history. Populated ONLY for a provider whose + // ContextCapabilities.compactor == true; a non-compactor provider MUST + // NOT receive this — for those providers this field arrives empty, which + // is indistinguishable from (and semantically equivalent to) "not + // provided". context.md §5.1. + repeated pluggableharness.content.v1.Message conversation_history = 9; + + // The current conversation-history token total, kernel-computed. One of + // the two signals a compactor provider needs to decide WHEN to compact + // without re-counting history itself — see data-types.md's compactor + // workflow discussion in the orbit of §5.1. + int64 history_tokens = 10; + + // The total assembled context size of the previous turn (all providers' + // sections combined), kernel-computed. The second compact-timing signal, + // alongside history_tokens above. + int64 assembled_tokens_last_turn = 11; +} + +// RenderRequest carries the opaque payload for the optional Render RPC. +message RenderRequest { + // The opaque emitted payload to render — see .claude/rules/grpc.md's + // Emit->Render->Paint carve-out. Never interpreted by the kernel or any + // other plugin; only the producing provider's own Render implementation + // understands its shape. + bytes payload = 1; + + // The schema version this payload was emitted against, so a Render + // implementation can detect drift between the version it was built + // against and the version live in a running session. See + // ../frontend/render-tree.md#schema-versioning for the canonical + // definition of this field's semantics (owned by the frontend/widget + // workstream; this field just carries the same value). + string schema_version = 2; +} + +// DescribeRequest carries no fields — Describe takes no request-scoped +// parameters. +message DescribeRequest {} diff --git a/api/pluggableharness/context/v1/rpc_response.proto b/api/pluggableharness/context/v1/rpc_response.proto new file mode 100644 index 0000000..db45a62 --- /dev/null +++ b/api/pluggableharness/context/v1/rpc_response.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; + +package pluggableharness.context.v1; + +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/content/v1/types.proto"; +import "pluggableharness/context/v1/types.proto"; +import "pluggableharness/render/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/context/proto/v1;contextv1"; + +// Unary response messages for the context provider protocol's RPCs. + +// GetCapabilitiesResponse wraps ContextCapabilities for the RPC signature, +// per this repo's per-RPC envelope convention (.claude/rules/proto.md). +message GetCapabilitiesResponse { + ContextCapabilities capabilities = 1; +} + +// ConfigureResponse is empty on success. A Configure failure (e.g. a +// declared source path/glob resolving to nothing) surfaces as a gRPC status +// carrying a ContextError in its structured detail, per +// .claude/rules/grpc.md — not as an in-band field on this message. +message ConfigureResponse {} + +// ContextContribution is Contribute's response: the full, possibly-modified +// section chain, with this provider's own section appended — never a +// delta. context.md §4. +message ContextContribution { + // The full accumulated chain, in declaration order, including this + // provider's own new or updated section(s). + repeated pluggableharness.content.v1.ContextSection sections = 1; + + // The session's conversation history, rewritten to replace what was sent + // in ContextRequest.conversation_history. MAY be included by a compactor + // provider (ContextCapabilities.compactor == true) alongside its section + // contribution. When present, the kernel MUST replace the turn's + // conversation history with this before the next model call. context.md + // §5.1. + repeated pluggableharness.content.v1.Message rewritten_history = 2; +} + +// RenderResponse wraps the rendered output of the optional Render RPC. +message RenderResponse { + // The rendered tree, per the general Emit->Render->Paint pipeline + // (frontend.md §1). context.md §9. + pluggableharness.render.v1.RenderTree tree = 1; +} + +// DescribeResponse reports this plugin build's own identity. See the +// Describe RPC comment on ContextService above. +message DescribeResponse { + // This plugin build's identity: name, version, source, category, + // protocol_version. + pluggableharness.common.v1.ProducerRef producer = 1; +} diff --git a/api/pluggableharness/context/v1/service.proto b/api/pluggableharness/context/v1/service.proto new file mode 100644 index 0000000..e65ca55 --- /dev/null +++ b/api/pluggableharness/context/v1/service.proto @@ -0,0 +1,75 @@ +syntax = "proto3"; + +// Package pluggableharness.context.v1 defines the context provider plugin protocol +// described in specifications/context.md — plugins that hook +// context-assemble and contribute content to the prompt before each model +// call (e.g. a CLAUDE.md reader, an AGENTS.md reader, a git-status/file-tree +// summarizer). See .claude/rules/proto.md. +// +// ContextSection and Stability — the section chain this protocol assembles +// and the turn-to-turn-change hint each section carries — are defined in +// pluggableharness.content.v1, not here: that chain is consumed by +// both this protocol and the model provider's completion request +// (forthcoming), so it's homed alongside content.v1's other shared content +// shapes rather than duplicated or owned by only one consumer. +package pluggableharness.context.v1; + +import "pluggableharness/context/v1/rpc_request.proto"; +import "pluggableharness/context/v1/rpc_response.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/context/proto/v1;contextv1"; + +// ContextService is the context provider plugin protocol described in +// specifications/context.md. A context provider plugin exposes +// GetCapabilities, Configure, and Contribute (context.md §1, all MUST); it +// MAY additionally implement Render (§9). +service ContextService { + // GetCapabilities reports this provider's static properties — its + // requested token budget, content stability, whether it acts as a + // compactor, and any slash commands or config schema it declares. + // context.md §2. + rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); + + // Configure delivers this provider's agent.hcl config block, already + // decoded via the schema-to-cty bridge. context.md §3. MUST reject with a + // structured error (surfaced as a ContextError in the gRPC status detail, + // per .claude/rules/grpc.md) if a declared source path/glob resolves to + // nothing on disk, rather than deferring to a silent-empty Contribute at + // first call. + rpc Configure(ConfigureRequest) returns (ConfigureResponse); + + // Contribute is the context-assemble RPC: the kernel invokes it at least + // once per turn, before each model call, and the provider returns the + // full accumulated ContextSection chain with its own section appended. + // context.md §1 and §4 (both MUST). Deliberately unary, not streamed — + // unlike a model provider's StreamCompletion, context assembly happens + // before the model call starts and convention-file/orientation content is + // small enough not to need token-level streaming. Contribute MUST return + // the full accumulated section chain (this provider's own section + // appended to prior_sections), never a delta. + // + // buf:lint:ignore RPC_REQUEST_STANDARD_NAME + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Request/response are the bare "ContextRequest"/"ContextContribution" + // per context.md §4's literal spec. Neither is reused by another RPC in + // this file (no uniqueness violation) — this is purely a naming-style + // preference, and the spec's own names carry real documentation value. + rpc Contribute(ContextRequest) returns (ContextContribution); + + // Render optionally renders this provider's contribution via the general + // Emit->Render->Paint pipeline, e.g. to display an injected CLAUDE.md + // section collapsed by default in a transcript view. context.md §9. MAY + // be implemented; if not, the kernel falls back to its generic default + // rendering. + rpc Render(RenderRequest) returns (RenderResponse); + + // Describe reports this plugin build's own identity — {name, version, + // source, category, protocol_version} via ProducerRef — independent of + // any lock-file entry. This is the mechanism a dev_overrides-resolved + // binary (which has no provider {} lock entry to read identity from) + // uses to self-report at connection time; see + // docs/specifications/configuration/lock-file.md's dev_overrides note, + // which is the canonical explanation for this RPC across every plugin + // category that gains it in this protocol revision. + rpc Describe(DescribeRequest) returns (DescribeResponse); +} diff --git a/api/pluggableharness/context/v1/types.proto b/api/pluggableharness/context/v1/types.proto new file mode 100644 index 0000000..9a78a94 --- /dev/null +++ b/api/pluggableharness/context/v1/types.proto @@ -0,0 +1,52 @@ +syntax = "proto3"; + +package pluggableharness.context.v1; + +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/config/v1/types.proto"; +import "pluggableharness/content/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/context/proto/v1;contextv1"; + +// Domain messages for the context provider protocol. + +// ContextCapabilities reports a context provider's static properties. +// context.md §2. stability and compactor MUST be re-queryable cheaply and +// MUST NOT depend on a live read of the content source — a provider reading +// CLAUDE.md declares stability as a fixed property of the plugin, not a +// per-call judgment. +message ContextCapabilities { + // The token cap this provider requests if agent.hcl does not override it. + // MUST be set. context.md §2, §6. + int64 default_token_budget = 1; + + // Whether this provider's contributed content changes turn to turn. MUST + // be set. context.md §2, §7. + pluggableharness.content.v1.Stability stability = 2; + + // Whether this provider acts as a compactor: MAY rewrite, merge, or drop + // other providers' sections in the chain it receives, and MAY receive + // conversation_history on ContextRequest and return rewritten_history. + // MUST be set; defaults to false. context.md §5, §5.1. + bool compactor = 3; + + // Prompt-expansion slash commands this provider contributes. MAY be + // empty. context.md §2, configuration.md §5. A direct-invoke command + // is declared by a slashcommand.v1 provider instead + // (specifications/slashcommand/), never here. + repeated pluggableharness.common.v1.PromptExpansionSpec slash_commands = 4; + + // This provider's agent.hcl config schema, advertised so the kernel knows + // what fields Configure accepts. configuration.md §4. + pluggableharness.config.v1.ConfigSchema config_schema = 5; + + // Which hook points (agent-loop/hook-dispatch.md) this provider declares + // HookSubscriberService.DispatchHook subscriptions for, advertised + // up front alongside its other static properties. MAY be empty — a + // context provider with no hook{} blocks in agent.hcl never has + // DispatchHook called on it. The enum itself lives in common.v1, not + // hook.v1 — hook.v1 imports this package's model/tool/plan dependencies, + // so a category capability message importing hook.v1 directly would + // cycle back through it; common.v1 is the shared leaf package instead. + repeated pluggableharness.common.v1.HookPoint supported_hook_points = 6; +} diff --git a/api/pluggableharness/event/v1/event.proto b/api/pluggableharness/event/v1/events.proto similarity index 95% rename from api/pluggableharness/event/v1/event.proto rename to api/pluggableharness/event/v1/events.proto index f227b29..329c620 100644 --- a/api/pluggableharness/event/v1/event.proto +++ b/api/pluggableharness/event/v1/events.proto @@ -53,12 +53,13 @@ syntax = "proto3"; // risking an import cycle back into any of them. package pluggableharness.event.v1; -import "pluggableharness/common/v1/common.proto"; -import "pluggableharness/content/v1/content.proto"; -import "pluggableharness/hook/v1/hook.proto"; -import "pluggableharness/model/v1/model.proto"; -import "pluggableharness/plan/v1/plan.proto"; -import "pluggableharness/tool/v1/tool.proto"; +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/content/v1/types.proto"; +import "pluggableharness/hook/v1/errors.proto"; +import "pluggableharness/model/v1/types.proto"; +import "pluggableharness/plan/v1/types.proto"; +import "pluggableharness/tool/v1/errors.proto"; +import "pluggableharness/tool/v1/types.proto"; option go_package = "github.com/pluggableharness/agent/pkg/event/proto/v1;eventv1"; @@ -129,7 +130,7 @@ message ApplyEvent { // plan.v1.ApplyResult — homed in plan.v1, not redefined here, so this // event's payload and hook.v1.PostApplyPayload's subject are the exact // same message rather than two independently-evolving shapes for the - // same data (plan.proto's own ApplyResult comment documents this + // same data (plan/v1/types.proto's own ApplyResult comment documents this // sharing rationale). pluggableharness.plan.v1.ApplyResult result = 1; } diff --git a/api/pluggableharness/frontend/v1/errors.proto b/api/pluggableharness/frontend/v1/errors.proto new file mode 100644 index 0000000..41cf987 --- /dev/null +++ b/api/pluggableharness/frontend/v1/errors.proto @@ -0,0 +1,58 @@ +syntax = "proto3"; + +package pluggableharness.frontend.v1; + +option go_package = "github.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1"; + +// The frontend provider protocol's error taxonomy. + +// FrontendErrorCategory classifies a FrontendError, per the error taxonomy +// in frontend.md §7. +enum FrontendErrorCategory { + // Zero value. Never valid for a real error; its presence on the wire + // means a caller forgot to set the field. + FRONTEND_ERROR_CATEGORY_UNSPECIFIED = 0; + // A RenderTree or PlacedContent could not be displayed. + FRONTEND_ERROR_CATEGORY_RENDER_FAILED = 1; + // A ClientEvent was malformed or referenced an unknown/already-resolved + // id (e.g. a plan_decision or interactive_response naming an item that + // was already resolved by another attached frontend, per frontend.md + // §3.3's first-response-wins arbitration). + FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT = 2; + // A PlacedContent named a Region this frontend cannot honor. + FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED = 3; + // An error that does not fit any other category. + FRONTEND_ERROR_CATEGORY_UNKNOWN = 4; + // AttachSession, ResumeSession, DetachSession, or ListSessions' + // parent_session_id filter named a session_id the kernel has no record + // of. + FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND = 5; + // CreateSession failed — an invalid profile, or an unusable + // working_directory. + FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED = 6; + // A session-mutating control event targeted a session currently + // SESSION_STATUS_RUNNING in a way that conflicts with that (reserved + // for future session-mutating control events; no current variant in + // this protocol revision triggers it, since DetachSession is always + // safe on a running session). + FRONTEND_ERROR_CATEGORY_SESSION_BUSY = 7; + // ResumeSession named a session file with a newer PRAGMA user_version + // than this kernel understands (state-backend.md §"Schema migration"). + FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW = 8; + // A user_message (or any other new-turn-inducing event) targeted a + // session attached replay-only per frontend.md §"Resume and re-open + // semantics" — a bound-exhausted (error_max_*) or FAILED session + // resumed via ResumeSession. Distinct from SESSION_BUSY: the session + // isn't running, it's terminal and specifically barred from new turns. + FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY = 9; +} + +// FrontendError is the structured error type for this category, per +// frontend.md §7. Carried in ServerEvent.Error and in the structured detail +// of a gRPC status returned from Configure. +message FrontendError { + // The error's category. + FrontendErrorCategory category = 1; + // A human-readable message. + string message = 2; +} diff --git a/api/pluggableharness/frontend/v1/frontend.proto b/api/pluggableharness/frontend/v1/events.proto similarity index 67% rename from api/pluggableharness/frontend/v1/frontend.proto rename to api/pluggableharness/frontend/v1/events.proto index b9505ff..9f3bc95 100644 --- a/api/pluggableharness/frontend/v1/frontend.proto +++ b/api/pluggableharness/frontend/v1/events.proto @@ -1,132 +1,20 @@ syntax = "proto3"; -// Package pluggableharness.frontend.v1 defines the frontend provider plugin protocol -// described in specifications/frontend.md §3 (Attach, ServerEvent, -// ClientEvent, ...). package pluggableharness.frontend.v1; import "google/protobuf/struct.proto"; -import "pluggableharness/common/v1/common.proto"; -import "pluggableharness/config/v1/config.proto"; -import "pluggableharness/content/v1/content.proto"; -import "pluggableharness/model/v1/model.proto"; -import "pluggableharness/plan/v1/plan.proto"; -import "pluggableharness/render/v1/render.proto"; -import "pluggableharness/session/v1/session.proto"; -import "pluggableharness/slashcommand/v1/slashcommand.proto"; +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/content/v1/types.proto"; +import "pluggableharness/frontend/v1/errors.proto"; +import "pluggableharness/model/v1/types.proto"; +import "pluggableharness/plan/v1/types.proto"; +import "pluggableharness/render/v1/types.proto"; +import "pluggableharness/session/v1/types.proto"; +import "pluggableharness/slashcommand/v1/types.proto"; option go_package = "github.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1"; -// FrontendService implements the frontend provider protocol described in -// specifications/frontend.md §3. -service FrontendService { - // GetCapabilities returns this frontend's slash commands and config - // schema. Unary. frontend.md §3.1. - rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); - - // Configure applies this provider's `agent.hcl` configuration, validated - // against the schema returned by GetCapabilities (configuration.md §4). - // Unary. frontend.md §3.1. - rpc Configure(ConfigureRequest) returns (ConfigureResponse); - - // Attach opens ONE multiplexed, connection-scoped bidirectional event - // channel between the kernel and this frontend connection — not a - // per-session stream. A frontend subscribes individual sessions onto - // this one stream via the session-control ClientEvent variants - // (create_session/attach_session/resume_session/detach_session), and - // unsubscribes the same way; connection-level operations - // (list_sessions, the aggregate slash-command registry) have a natural - // home here precisely because the stream isn't tied to one session. - // ServerEvents flow from kernel to frontend, ClientEvents flow from - // frontend to kernel, both directions live for the duration of the - // stream. Bidirectional streaming — frontend.md §"Transport", and - // (along with the kernel callback channel) one of only two genuinely - // bidirectional RPCs in this protocol series (see .claude/rules/grpc.md). - // - // Multiple frontends MAY subscribe to the same session concurrently on - // their own Attach streams (frontend.md §"Session scope"): every - // ServerEvent for a given session broadcasts identically to every - // frontend subscribed to that session, with no partitioning and no - // "primary" frontend. ClientEvents are processed in kernel arrival - // order; for ClientEvent.plan_decision and - // ClientEvent.interactive_response specifically, which name a pending - // item by id within a session, the kernel applies first-response-wins - // arbitration per session and MUST reject any later response for an - // already-resolved item with a distinct error back to its sender. - // - // buf:lint:ignore RPC_REQUEST_STANDARD_NAME - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Stream element types are the bare "ClientEvent"/"ServerEvent" per - // frontend.md §3.2's literal spec — names used throughout this project's - // specs and rules, not just here. Neither is reused by another RPC (no - // uniqueness violation); renaming to Attach*Request/Response would only - // satisfy a style convention while discarding real spec traceability. - rpc Attach(stream ClientEvent) returns (stream ServerEvent); - - // Describe reports this plugin build's own identity — {name, version, - // source, category, protocol_version} — directly from the running - // process, rather than the kernel inferring it from a lock-file row. - // Every one of the six category protocols gains this identical RPC in - // this protocol revision; it exists specifically for a - // `dev_overrides`-resolved binary (configuration/lock-file.md's - // "dev_overrides and identity without a lock entry"), which has no - // provider {} lock-file entry to read identity from at all. - rpc Describe(DescribeRequest) returns (DescribeResponse); -} - -// DescribeRequest carries no fields — Describe takes no parameters. -message DescribeRequest {} - -// DescribeResponse reports this plugin build's own identity, obtained -// directly from the running process rather than a lock-file row — -// configuration/lock-file.md's "dev_overrides and identity without a lock -// entry". -message DescribeResponse { - pluggableharness.common.v1.ProducerRef producer = 1; -} - -// GetCapabilitiesRequest carries no fields; capability discovery is not -// parameterized. -message GetCapabilitiesRequest {} - -// GetCapabilitiesResponse wraps FrontendCapabilities for the RPC signature, -// per this repo's per-RPC envelope convention (.claude/rules/proto.md). -message GetCapabilitiesResponse { - FrontendCapabilities capabilities = 1; -} - -// FrontendCapabilities is this frontend's static self-description, returned -// by GetCapabilities (frontend.md §3.1). -message FrontendCapabilities { - // Slash commands this frontend contributes. MAY be empty. - repeated pluggableharness.slashcommand.v1.SlashCommandSpec slash_commands = 1; - // This provider's `agent.hcl` configuration schema (configuration.md §4). - pluggableharness.config.v1.ConfigSchema config_schema = 2; - // Regions this frontend proactively declares it can render into. A - // complement to, not a replacement for, the reactive - // FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED error a placement the - // frontend can't honor still produces — this lets a producer route - // content preferentially without waiting to find out the hard way. - repeated pluggableharness.render.v1.Region supported_regions = 3; - // Hook points this frontend can subscribe to (agent-loop/hook-dispatch.md), - // so a mis-declared agent.hcl hook{} block naming an unsupported point - // can be rejected at config-load time rather than failing at first - // dispatch. - repeated pluggableharness.common.v1.HookPoint supported_hook_points = 4; -} - -// ConfigureRequest carries this provider's `agent.hcl` configuration as a -// dynamic Struct, shaped per the ConfigSchema returned by GetCapabilities -// (configuration.md §4). -message ConfigureRequest { - // The configuration value, validated against this provider's ConfigSchema. - google.protobuf.Struct config = 1; -} - -// ConfigureResponse is empty on success. Configuration errors surface as a -// gRPC status carrying a FrontendError in its structured detail -// (.claude/rules/grpc.md), not as an in-band field on this message. -message ConfigureResponse {} +// Occurrence-shaped messages flowing over the Attach bidirectional stream. // ServerEvent is one message the kernel sends to an attached frontend over // the single multiplexed Attach stream, described in frontend.md §3.2. @@ -296,11 +184,24 @@ message ServerEvent { // SlashCommandRegistry is the profile-scoped aggregate of every loaded // provider's declared slash commands for this session, per - // frontend.md §"Slash commands". + // frontend.md §"Slash commands" and specifications/slashcommand/. Two + // separate lists rather than one, since the two kinds are declared by + // different provider categories and dispatched differently — a + // frontend distinguishes them the same way it renders them (a `/name` + // lookup checks both), but the kernel keeps their namespaces distinct + // per-list while still enforcing one combined collision check across + // both at config-load time. message SlashCommandRegistry { - // Every registered command, name-collision-checked at config-load - // time (frontend.md §"Slash commands"). - repeated pluggableharness.slashcommand.v1.SlashCommandSpec commands = 1; + // Every registered direct-invoke command, declared by a + // slashcommand.v1 provider's own GetCapabilities response. + // Name-collision-checked (jointly with prompt_expansion_commands + // below) at config-load time (frontend.md §"Slash commands"). + repeated pluggableharness.slashcommand.v1.SlashCommandSpec direct_invoke_commands = 1; + + // Every registered prompt-expansion command, declared by any + // category's own capability response. Name-collision-checked + // (jointly with direct_invoke_commands above) at config-load time. + repeated pluggableharness.common.v1.PromptExpansionSpec prompt_expansion_commands = 2; } // UsageUpdate carries one turn's token/cost accounting and the @@ -464,12 +365,12 @@ message ClientEvent { } // ActionTrigger is dispatched when a user activates a RenderNode's - // ActionNode (render.proto's ActionNode, frontend.md §5.1). The kernel + // ActionNode (render/v1/types.proto's ActionNode, frontend.md §5.1). The kernel // handles this identically to a direct_invoke slash command: the normal // Invoke/plan-apply pipeline including policy evaluation, with no model // turn. message ActionTrigger { - // The originating ActionNode's id (render.proto's ActionNode.id). + // The originating ActionNode's id (render/v1/types.proto's ActionNode.id). string node_id = 1; // The tool operation to invoke (tool.md §2 ToolSchema.name), echoed // unchanged from the originating ActionNode.tool_name. @@ -479,7 +380,7 @@ message ClientEvent { google.protobuf.Struct args = 3; // The declared name of the tool provider plugin `tool_name` belongs // to, echoed unchanged from the originating ActionNode.provider - // (render.proto) — tool_name is only unique per provider. + // (render/v1/types.proto) — tool_name is only unique per provider. string provider = 4; } @@ -566,54 +467,3 @@ message ClientEvent { bool roots_only = 4; } } - -// FrontendErrorCategory classifies a FrontendError, per the error taxonomy -// in frontend.md §7. -enum FrontendErrorCategory { - // Zero value. Never valid for a real error; its presence on the wire - // means a caller forgot to set the field. - FRONTEND_ERROR_CATEGORY_UNSPECIFIED = 0; - // A RenderTree or PlacedContent could not be displayed. - FRONTEND_ERROR_CATEGORY_RENDER_FAILED = 1; - // A ClientEvent was malformed or referenced an unknown/already-resolved - // id (e.g. a plan_decision or interactive_response naming an item that - // was already resolved by another attached frontend, per frontend.md - // §3.3's first-response-wins arbitration). - FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT = 2; - // A PlacedContent named a Region this frontend cannot honor. - FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED = 3; - // An error that does not fit any other category. - FRONTEND_ERROR_CATEGORY_UNKNOWN = 4; - // AttachSession, ResumeSession, DetachSession, or ListSessions' - // parent_session_id filter named a session_id the kernel has no record - // of. - FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND = 5; - // CreateSession failed — an invalid profile, or an unusable - // working_directory. - FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED = 6; - // A session-mutating control event targeted a session currently - // SESSION_STATUS_RUNNING in a way that conflicts with that (reserved - // for future session-mutating control events; no current variant in - // this protocol revision triggers it, since DetachSession is always - // safe on a running session). - FRONTEND_ERROR_CATEGORY_SESSION_BUSY = 7; - // ResumeSession named a session file with a newer PRAGMA user_version - // than this kernel understands (state-backend.md §"Schema migration"). - FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW = 8; - // A user_message (or any other new-turn-inducing event) targeted a - // session attached replay-only per frontend.md §"Resume and re-open - // semantics" — a bound-exhausted (error_max_*) or FAILED session - // resumed via ResumeSession. Distinct from SESSION_BUSY: the session - // isn't running, it's terminal and specifically barred from new turns. - FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY = 9; -} - -// FrontendError is the structured error type for this category, per -// frontend.md §7. Carried in ServerEvent.Error and in the structured detail -// of a gRPC status returned from Configure. -message FrontendError { - // The error's category. - FrontendErrorCategory category = 1; - // A human-readable message. - string message = 2; -} diff --git a/api/pluggableharness/frontend/v1/rpc_request.proto b/api/pluggableharness/frontend/v1/rpc_request.proto new file mode 100644 index 0000000..39e4541 --- /dev/null +++ b/api/pluggableharness/frontend/v1/rpc_request.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package pluggableharness.frontend.v1; + +import "google/protobuf/struct.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1"; + +// Request messages for the frontend provider protocol's RPCs. + +// GetCapabilitiesRequest carries no fields; capability discovery is not +// parameterized. +message GetCapabilitiesRequest {} + +// ConfigureRequest carries this provider's `agent.hcl` configuration as a +// dynamic Struct, shaped per the ConfigSchema returned by GetCapabilities +// (configuration.md §4). +message ConfigureRequest { + // The configuration value, validated against this provider's ConfigSchema. + google.protobuf.Struct config = 1; +} + +// DescribeRequest carries no fields — Describe takes no parameters. +message DescribeRequest {} diff --git a/api/pluggableharness/frontend/v1/rpc_response.proto b/api/pluggableharness/frontend/v1/rpc_response.proto new file mode 100644 index 0000000..e16d5de --- /dev/null +++ b/api/pluggableharness/frontend/v1/rpc_response.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +package pluggableharness.frontend.v1; + +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/frontend/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1"; + +// Unary response messages for the frontend provider protocol's RPCs. + +// GetCapabilitiesResponse wraps FrontendCapabilities for the RPC signature, +// per this repo's per-RPC envelope convention (.claude/rules/proto.md). +message GetCapabilitiesResponse { + FrontendCapabilities capabilities = 1; +} + +// ConfigureResponse is empty on success. Configuration errors surface as a +// gRPC status carrying a FrontendError in its structured detail +// (.claude/rules/grpc.md), not as an in-band field on this message. +message ConfigureResponse {} + +// DescribeResponse reports this plugin build's own identity, obtained +// directly from the running process rather than a lock-file row — +// configuration/lock-file.md's "dev_overrides and identity without a lock +// entry". +message DescribeResponse { + pluggableharness.common.v1.ProducerRef producer = 1; +} diff --git a/api/pluggableharness/frontend/v1/service.proto b/api/pluggableharness/frontend/v1/service.proto new file mode 100644 index 0000000..c1ab011 --- /dev/null +++ b/api/pluggableharness/frontend/v1/service.proto @@ -0,0 +1,69 @@ +syntax = "proto3"; + +// Package pluggableharness.frontend.v1 defines the frontend provider plugin protocol +// described in specifications/frontend.md §3 (Attach, ServerEvent, +// ClientEvent, ...). +package pluggableharness.frontend.v1; + +import "pluggableharness/frontend/v1/events.proto"; +import "pluggableharness/frontend/v1/rpc_request.proto"; +import "pluggableharness/frontend/v1/rpc_response.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1"; + +// FrontendService implements the frontend provider protocol described in +// specifications/frontend.md §3. +service FrontendService { + // GetCapabilities returns this frontend's slash commands and config + // schema. Unary. frontend.md §3.1. + rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); + + // Configure applies this provider's `agent.hcl` configuration, validated + // against the schema returned by GetCapabilities (configuration.md §4). + // Unary. frontend.md §3.1. + rpc Configure(ConfigureRequest) returns (ConfigureResponse); + + // Attach opens ONE multiplexed, connection-scoped bidirectional event + // channel between the kernel and this frontend connection — not a + // per-session stream. A frontend subscribes individual sessions onto + // this one stream via the session-control ClientEvent variants + // (create_session/attach_session/resume_session/detach_session), and + // unsubscribes the same way; connection-level operations + // (list_sessions, the aggregate slash-command registry) have a natural + // home here precisely because the stream isn't tied to one session. + // ServerEvents flow from kernel to frontend, ClientEvents flow from + // frontend to kernel, both directions live for the duration of the + // stream. Bidirectional streaming — frontend.md §"Transport", and + // (along with the kernel callback channel) one of only two genuinely + // bidirectional RPCs in this protocol series (see .claude/rules/grpc.md). + // + // Multiple frontends MAY subscribe to the same session concurrently on + // their own Attach streams (frontend.md §"Session scope"): every + // ServerEvent for a given session broadcasts identically to every + // frontend subscribed to that session, with no partitioning and no + // "primary" frontend. ClientEvents are processed in kernel arrival + // order; for ClientEvent.plan_decision and + // ClientEvent.interactive_response specifically, which name a pending + // item by id within a session, the kernel applies first-response-wins + // arbitration per session and MUST reject any later response for an + // already-resolved item with a distinct error back to its sender. + // + // buf:lint:ignore RPC_REQUEST_STANDARD_NAME + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Stream element types are the bare "ClientEvent"/"ServerEvent" per + // frontend.md §3.2's literal spec — names used throughout this project's + // specs and rules, not just here. Neither is reused by another RPC (no + // uniqueness violation); renaming to Attach*Request/Response would only + // satisfy a style convention while discarding real spec traceability. + rpc Attach(stream ClientEvent) returns (stream ServerEvent); + + // Describe reports this plugin build's own identity — {name, version, + // source, category, protocol_version} — directly from the running + // process, rather than the kernel inferring it from a lock-file row. + // Every one of the seven category protocols gains this identical RPC in + // this protocol revision; it exists specifically for a + // `dev_overrides`-resolved binary (configuration/lock-file.md's + // "dev_overrides and identity without a lock entry"), which has no + // provider {} lock-file entry to read identity from at all. + rpc Describe(DescribeRequest) returns (DescribeResponse); +} diff --git a/api/pluggableharness/frontend/v1/types.proto b/api/pluggableharness/frontend/v1/types.proto new file mode 100644 index 0000000..c67aa00 --- /dev/null +++ b/api/pluggableharness/frontend/v1/types.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +package pluggableharness.frontend.v1; + +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/config/v1/types.proto"; +import "pluggableharness/render/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1"; + +// Domain messages for the frontend provider protocol. + +// FrontendCapabilities is this frontend's static self-description, returned +// by GetCapabilities (frontend.md §3.1). +message FrontendCapabilities { + // Prompt-expansion slash commands this frontend contributes. MAY be + // empty. A direct-invoke command is declared by a slashcommand.v1 + // provider instead (specifications/slashcommand/), never here. + repeated pluggableharness.common.v1.PromptExpansionSpec slash_commands = 1; + // This provider's `agent.hcl` configuration schema (configuration.md §4). + pluggableharness.config.v1.ConfigSchema config_schema = 2; + // Regions this frontend proactively declares it can render into. A + // complement to, not a replacement for, the reactive + // FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED error a placement the + // frontend can't honor still produces — this lets a producer route + // content preferentially without waiting to find out the hard way. + repeated pluggableharness.render.v1.Region supported_regions = 3; + // Hook points this frontend can subscribe to (agent-loop/hook-dispatch.md), + // so a mis-declared agent.hcl hook{} block naming an unsupported point + // can be rejected at config-load time rather than failing at first + // dispatch. + repeated pluggableharness.common.v1.HookPoint supported_hook_points = 4; +} diff --git a/api/pluggableharness/hook/v1/errors.proto b/api/pluggableharness/hook/v1/errors.proto new file mode 100644 index 0000000..1dfa5c9 --- /dev/null +++ b/api/pluggableharness/hook/v1/errors.proto @@ -0,0 +1,66 @@ +syntax = "proto3"; + +package pluggableharness.hook.v1; + +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/hook/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/hook/proto/v1;hookv1"; + +// The hook-dispatch error taxonomy. + +// HookErrorCategory classifies why a hook dispatch to one subscriber +// failed. Deliberately its own enum, not reused from tool.v1 or model.v1 — +// per .claude/rules/proto.md, a category proto's error taxonomy is never +// merged with another category's even where the surrounding shape is +// parallel. +enum HookErrorCategory { + // Zero value. Never valid for a real error; its presence on the wire + // means a caller forgot to set the field. + HOOK_ERROR_CATEGORY_UNSPECIFIED = 0; + // A transform subscriber raised an error or returned a malformed + // response. Aborts the remainder of that hook's chain + // (agent-loop/hook-dispatch.md#subscriber-error-handling). + HOOK_ERROR_CATEGORY_TRANSFORM_FAILED = 1; + // A veto subscriber raised an error. Treated identically to an + // explicit deny — fail-closed + // (agent-loop/hook-dispatch.md#subscriber-error-handling). + HOOK_ERROR_CATEGORY_VETO_FAILED = 2; + // The subscriber exceeded its per-subscriber deadline + // (agent-loop/hook-dispatch.md#timeout-behavior). For a veto + // subscriber, resolved identically to HOOK_ERROR_CATEGORY_VETO_FAILED — + // fail-closed deny. + HOOK_ERROR_CATEGORY_TIMEOUT = 3; + // The subscriber's DispatchHookResponse didn't match what its declared + // HookMode requires — wrong oneof variant, or (for transform) a + // payload variant that doesn't match the request's, or a mutation to a + // field this hook point doesn't document as transform-mutable. + HOOK_ERROR_CATEGORY_INVALID_RESPONSE = 4; + // The subscriber's plugin subprocess died mid-dispatch (transport + // error, not a graceful error the plugin chose to return). MUST be + // kernel-synthesized only, mirroring + // tool/conformance.md#error-taxonomy's TOOL_ERROR_CATEGORY_PROCESS_CRASHED + // — a plugin process that crashes obviously cannot emit this itself. + HOOK_ERROR_CATEGORY_PROCESS_CRASHED = 5; + // Anything else. + HOOK_ERROR_CATEGORY_UNKNOWN = 6; +} + +// HookError is the structured detail describing one failed hook dispatch. +// Also the payload shape the kernel-synthesized EVENT_KIND_HOOK_ERROR +// event carries (kernel.v1.EventKind, state-backend.md §The kind enum) — +// the forthcoming event.v1 package's HookErrorEvent wraps this same +// message rather than redefining it. +message HookError { + // Which hook point the failing dispatch was for. MUST be set. + pluggableharness.common.v1.HookPoint point = 1; + // Which plugin build the failing subscriber was. MUST be set. + pluggableharness.common.v1.ProducerRef subscriber = 2; + // The HookMode the failing subscription was declared under. MUST be + // set. + HookMode mode = 3; + // Which category of failure this is. MUST be set. + HookErrorCategory category = 4; + // Human-readable detail, e.g. the raw subscriber error message. + string message = 5; +} diff --git a/api/pluggableharness/hook/v1/events.proto b/api/pluggableharness/hook/v1/events.proto new file mode 100644 index 0000000..43061ea --- /dev/null +++ b/api/pluggableharness/hook/v1/events.proto @@ -0,0 +1,164 @@ +syntax = "proto3"; + +package pluggableharness.hook.v1; + +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/content/v1/types.proto"; +import "pluggableharness/model/v1/types.proto"; +import "pluggableharness/plan/v1/types.proto"; +import "pluggableharness/session/v1/types.proto"; +import "pluggableharness/tool/v1/errors.proto"; +import "pluggableharness/tool/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/hook/proto/v1;hookv1"; + +// The HookPayload event envelope and its per-hook-point payload variants. + +// The HookPoint enum itself lives in common.v1 (common/v1/types.proto), +// not here: hook.v1 imports several category packages (model, tool, plan, +// session) for its typed payloads, so a category package advertising +// `supported_hook_points` in its capability message could not import this +// package back without a cycle. common.v1 is the import-nothing leaf both +// sides can safely share. + +// HookPayload carries one hook point's data. Exactly one oneof variant is +// set; which variant is set *is* the point being dispatched — the +// parallel common.v1.HookPoint enum exists only for contexts (HookError) +// that have no oneof to infer the point from. +message HookPayload { + oneof payload { + // Fires at HOOK_POINT_SESSION_START. + SessionStartPayload session_start = 1; + // Fires at HOOK_POINT_PRE_MODEL_CALL. + PreModelCallPayload pre_model_call = 2; + // Fires at HOOK_POINT_POST_MODEL_RESPONSE. + PostModelResponsePayload post_model_response = 3; + // Fires at HOOK_POINT_PRE_TOOL_CALL. + PreToolCallPayload pre_tool_call = 4; + // Fires at HOOK_POINT_PLAN_READY. + PlanReadyPayload plan_ready = 5; + // Fires at HOOK_POINT_POST_TOOL_CALL. + PostToolCallPayload post_tool_call = 6; + // Fires at HOOK_POINT_POST_APPLY. + PostApplyPayload post_apply = 7; + // Fires at HOOK_POINT_SESSION_END. + SessionEndPayload session_end = 8; + } +} + +// SessionStartPayload fires once, at session creation. No field here is +// transform-mutable per agent-loop/hook-dispatch.md's per-point mutable- +// field table — session identity and startup parameters are fixed by the +// time this hook fires. +message SessionStartPayload { + // The new session's id. MUST be set. Immutable. + string session_id = 1; + // The agent.hcl profile this session was created under. MUST be set. + // Immutable. + string profile = 2; + // The parent session's id, when this is a sub-agent session + // (agent-loop/subagents.md). Absent for a root session. Immutable. + optional string parent_session_id = 3; + // The session's working directory. MUST be set. Immutable. + string working_directory = 4; +} + +// PreModelCallPayload fires immediately before a model provider's +// StreamCompletion is invoked. A transform subscriber MAY rewrite +// `messages` (e.g. redaction, injection of an additional instruction); it +// MUST NOT alter `model` — a hook subscriber does not get to silently +// reroute a turn to a different model than the one the turn algorithm +// already resolved. +message PreModelCallPayload { + // The messages about to be sent to the model. Transform-mutable. + repeated pluggableharness.content.v1.Message messages = 1; + // The model this call targets. Immutable. + pluggableharness.model.v1.ModelRef model = 2; +} + +// PostModelResponsePayload fires immediately after a model turn's +// canonical message has been assembled, before it is persisted +// (EVENT_KIND_MESSAGE). Primarily an observe-mode point (memory providers +// recording the turn, widgets displaying live usage) — no field here is +// documented transform-mutable in agent-loop/hook-dispatch.md's per-point +// table; a transform subscriber at this point MUST return the payload +// unchanged. +message PostModelResponsePayload { + // The assembled assistant message. MUST be set. + pluggableharness.content.v1.Message message = 1; + // Which model-provider build produced `message`, for supersedes/replay + // attribution (architecture.md §Versioning & schema drift — + // "supersedes"). MUST be set. + pluggableharness.common.v1.ProducerRef model = 2; + // Token usage for the completion that produced `message`. MUST be set. + pluggableharness.model.v1.Usage usage = 3; + // The kernel-computed cost, in USD, of the completion that produced + // `message` (model/protocol.md#cost-computation — the provider never + // computes cost itself). MUST be set. + double cost_usd = 4; +} + +// PreToolCallPayload fires immediately before an already-allowed plan +// item's tool call is applied. No field here is documented transform- +// mutable — a hook subscriber observes or vetoes an about-to-execute call, +// it does not get to silently rewrite its arguments; argument mutation is +// the plan/apply gate's own concern (agent-loop.md §5), not a general hook +// capability. +message PreToolCallPayload { + // The call about to be applied. MUST be set. Immutable. + pluggableharness.tool.v1.ToolCall call = 1; + // The originating plan item, carrying the policy decision that allowed + // this call through to apply. MUST be set. Immutable. + pluggableharness.plan.v1.PlanItem plan_item = 2; +} + +// PlanReadyPayload fires once a turn's Plan has been fully built, before +// the plan/apply gate dispatches it. This is the veto-bearing hook point: +// the kernel-privileged policy subscriber (architecture.md §Policy — +// first-party, not a plugin category) always runs here in HOOK_MODE_VETO. +// No field here is transform-mutable — a hook subscriber does not rewrite +// plan items; only the plan/apply gate itself and the policy veto affect +// what applies. +message PlanReadyPayload { + // The fully-built plan awaiting policy evaluation. MUST be set. + // Immutable. + pluggableharness.plan.v1.Plan plan = 1; +} + +// PostToolCallPayload fires immediately after a plan item's tool call +// reaches a terminal outcome. Observe-only in practice — no field here is +// documented transform-mutable. +message PostToolCallPayload { + // The call that completed. MUST be set. Immutable. + pluggableharness.tool.v1.ToolCall call = 1; + oneof outcome { + // The call's successful terminal result. + pluggableharness.tool.v1.ToolResult result = 2; + // The call's failed terminal result. + pluggableharness.tool.v1.ToolError error = 3; + } +} + +// PostApplyPayload fires once a turn's whole Plan has finished applying — +// every item has reached a terminal ApplyOutcome +// (pluggableharness.plan.v1.ApplyResult.ApplyOutcome). Reuses +// plan.v1.ApplyResult rather than defining its own per-item outcome shape, +// so the post-apply hook's subject and the EVENT_KIND_APPLY event (the +// forthcoming event.v1 package) are the exact same message. Observe-only — +// applying has already happened by the time this fires, so there is +// nothing left to transform. +message PostApplyPayload { + // The turn's per-item apply outcomes. MUST be set. + pluggableharness.plan.v1.ApplyResult apply = 1; +} + +// SessionEndPayload fires once, when a session reaches a terminal +// SessionStatus. No field here is transform-mutable — a session's outcome +// is already final by the time this fires. +message SessionEndPayload { + // The session that ended. MUST be set. Immutable. + string session_id = 1; + // The session's terminal status. MUST be set (never + // SESSION_STATUS_RUNNING). Immutable. + pluggableharness.session.v1.SessionStatus status = 2; +} diff --git a/api/pluggableharness/hook/v1/hook.proto b/api/pluggableharness/hook/v1/hook.proto deleted file mode 100644 index 7d4a6e8..0000000 --- a/api/pluggableharness/hook/v1/hook.proto +++ /dev/null @@ -1,364 +0,0 @@ -syntax = "proto3"; - -// Package pluggableharness.hook.v1 defines the hook-dispatch RPC surface -// described in agent-loop/hook-dispatch.md and architecture.md §Hook -// dispatch semantics: the wire contract the kernel uses to invoke any -// plugin (of any of the six categories) that declares a `hook{}` block in -// agent.hcl. This is deliberately one shared service rather than a -// per-category RPC — hashicorp/go-plugin (.claude/rules/plugin-runtime.md) -// muxes multiple gRPC services over one broker connection, so the kernel -// dials HookSubscriberService on the same subprocess that already serves -// that plugin's own category service. A plugin with no `hook{}` blocks in -// agent.hcl simply never has it called. -// -// context-assemble is deliberately absent from this surface's hook-point -// vocabulary (common.v1.HookPoint) — it stays on ContextService.Contribute -// (context/protocol.md#contribute-the-context-assemble-rpc; architecture.md -// §Hook dispatch semantics), which already carries the full accumulated -// ContextSection chain and doesn't need a second, competing dispatch path. -// This surface serves the other eight hook points only. -package pluggableharness.hook.v1; - -import "pluggableharness/common/v1/common.proto"; -import "pluggableharness/content/v1/content.proto"; -import "pluggableharness/model/v1/model.proto"; -import "pluggableharness/plan/v1/plan.proto"; -import "pluggableharness/session/v1/session.proto"; -import "pluggableharness/tool/v1/tool.proto"; - -option go_package = "github.com/pluggableharness/agent/pkg/hook/proto/v1;hookv1"; - -// HookSubscriberService is the hook-dispatch protocol described in -// agent-loop/hook-dispatch.md: the kernel invokes a subscribing plugin -// once per hook-point firing it's declared for, in agent.hcl declaration -// order alongside every other subscriber at that point -// (agent-loop/hook-dispatch.md#dispatch-order-and-payload-flow). Every -// plugin category MAY implement this service; whether the kernel ever -// dials it for a given plugin process is entirely a function of that -// plugin's own agent.hcl `hook{}` declarations, not its category. -service HookSubscriberService { - // DispatchHook delivers one hook-point firing to one subscriber. Unary, - // not streaming: one invocation is one request/one response - // (agent-loop/hook-dispatch.md#dispatch-order-and-payload-flow). Per- - // subscriber timeout is a ctx deadline the kernel sets - // (.claude/rules/grpc.md "Context and deadlines"; - // agent-loop/hook-dispatch.md#timeout-behavior), not a wire field on - // this request. Cancellation (the kernel closing the call because the - // turn is being aborted) is normal control flow, per - // .claude/rules/grpc.md — never logged as a failure. - rpc DispatchHook(DispatchHookRequest) returns (DispatchHookResponse); -} - -// The HookPoint enum itself lives in common.v1 (common/v1/common.proto), -// not here: hook.v1 imports several category packages (model, tool, plan, -// session) for its typed payloads, so a category package advertising -// `supported_hook_points` in its capability message could not import this -// package back without a cycle. common.v1 is the import-nothing leaf both -// sides can safely share. - -// HookMode is the operator-declared subscription mode for one plugin's -// hook{} block, per architecture.md §Hook dispatch semantics and -// agent-loop/hook-dispatch.md#dispatch-order-and-payload-flow. Mode is -// per-subscription (agent.hcl-declared), not per-payload — it is not -// carried in HookPayload itself, only echoed on DispatchHookRequest so the -// subscriber knows which of DispatchHookResponse's three outcome shapes is -// expected of it. -enum HookMode { - // Zero value. Never valid on the wire; its presence means a caller - // forgot to set the field. - HOOK_MODE_UNSPECIFIED = 0; - // Read-only, fire-and-forget. A raised error or malformed response is - // logged and dispatch continues; an observe subscriber can never alter - // the payload or abort the chain - // (agent-loop/hook-dispatch.md#subscriber-error-handling). - HOOK_MODE_OBSERVE = 1; - // Sequential chain member: receives the prior stage's payload, returns - // a modified version of the same variant. An error or malformed - // response aborts the remainder of that hook's chain and surfaces a - // hook_error event (agent-loop/hook-dispatch.md#subscriber-error-handling). - HOOK_MODE_TRANSFORM = 2; - // Returns an explicit allow/deny verdict. An error or timeout is - // treated identically to an explicit deny — fail-closed - // (agent-loop/hook-dispatch.md#timeout-behavior). - HOOK_MODE_VETO = 3; -} - -// HookPayload carries one hook point's data. Exactly one oneof variant is -// set; which variant is set *is* the point being dispatched — the -// parallel common.v1.HookPoint enum exists only for contexts (HookError) -// that have no oneof to infer the point from. -message HookPayload { - oneof payload { - // Fires at HOOK_POINT_SESSION_START. - SessionStartPayload session_start = 1; - // Fires at HOOK_POINT_PRE_MODEL_CALL. - PreModelCallPayload pre_model_call = 2; - // Fires at HOOK_POINT_POST_MODEL_RESPONSE. - PostModelResponsePayload post_model_response = 3; - // Fires at HOOK_POINT_PRE_TOOL_CALL. - PreToolCallPayload pre_tool_call = 4; - // Fires at HOOK_POINT_PLAN_READY. - PlanReadyPayload plan_ready = 5; - // Fires at HOOK_POINT_POST_TOOL_CALL. - PostToolCallPayload post_tool_call = 6; - // Fires at HOOK_POINT_POST_APPLY. - PostApplyPayload post_apply = 7; - // Fires at HOOK_POINT_SESSION_END. - SessionEndPayload session_end = 8; - } -} - -// SessionStartPayload fires once, at session creation. No field here is -// transform-mutable per agent-loop/hook-dispatch.md's per-point mutable- -// field table — session identity and startup parameters are fixed by the -// time this hook fires. -message SessionStartPayload { - // The new session's id. MUST be set. Immutable. - string session_id = 1; - // The agent.hcl profile this session was created under. MUST be set. - // Immutable. - string profile = 2; - // The parent session's id, when this is a sub-agent session - // (agent-loop/subagents.md). Absent for a root session. Immutable. - optional string parent_session_id = 3; - // The session's working directory. MUST be set. Immutable. - string working_directory = 4; -} - -// PreModelCallPayload fires immediately before a model provider's -// StreamCompletion is invoked. A transform subscriber MAY rewrite -// `messages` (e.g. redaction, injection of an additional instruction); it -// MUST NOT alter `model` — a hook subscriber does not get to silently -// reroute a turn to a different model than the one the turn algorithm -// already resolved. -message PreModelCallPayload { - // The messages about to be sent to the model. Transform-mutable. - repeated pluggableharness.content.v1.Message messages = 1; - // The model this call targets. Immutable. - pluggableharness.model.v1.ModelRef model = 2; -} - -// PostModelResponsePayload fires immediately after a model turn's -// canonical message has been assembled, before it is persisted -// (EVENT_KIND_MESSAGE). Primarily an observe-mode point (memory providers -// recording the turn, widgets displaying live usage) — no field here is -// documented transform-mutable in agent-loop/hook-dispatch.md's per-point -// table; a transform subscriber at this point MUST return the payload -// unchanged. -message PostModelResponsePayload { - // The assembled assistant message. MUST be set. - pluggableharness.content.v1.Message message = 1; - // Which model-provider build produced `message`, for supersedes/replay - // attribution (architecture.md §Versioning & schema drift — - // "supersedes"). MUST be set. - pluggableharness.common.v1.ProducerRef model = 2; - // Token usage for the completion that produced `message`. MUST be set. - pluggableharness.model.v1.Usage usage = 3; - // The kernel-computed cost, in USD, of the completion that produced - // `message` (model/protocol.md#cost-computation — the provider never - // computes cost itself). MUST be set. - double cost_usd = 4; -} - -// PreToolCallPayload fires immediately before an already-allowed plan -// item's tool call is applied. No field here is documented transform- -// mutable — a hook subscriber observes or vetoes an about-to-execute call, -// it does not get to silently rewrite its arguments; argument mutation is -// the plan/apply gate's own concern (agent-loop.md §5), not a general hook -// capability. -message PreToolCallPayload { - // The call about to be applied. MUST be set. Immutable. - pluggableharness.tool.v1.ToolCall call = 1; - // The originating plan item, carrying the policy decision that allowed - // this call through to apply. MUST be set. Immutable. - pluggableharness.plan.v1.PlanItem plan_item = 2; -} - -// PlanReadyPayload fires once a turn's Plan has been fully built, before -// the plan/apply gate dispatches it. This is the veto-bearing hook point: -// the kernel-privileged policy subscriber (architecture.md §Policy — -// first-party, not a plugin category) always runs here in HOOK_MODE_VETO. -// No field here is transform-mutable — a hook subscriber does not rewrite -// plan items; only the plan/apply gate itself and the policy veto affect -// what applies. -message PlanReadyPayload { - // The fully-built plan awaiting policy evaluation. MUST be set. - // Immutable. - pluggableharness.plan.v1.Plan plan = 1; -} - -// PostToolCallPayload fires immediately after a plan item's tool call -// reaches a terminal outcome. Observe-only in practice — no field here is -// documented transform-mutable. -message PostToolCallPayload { - // The call that completed. MUST be set. Immutable. - pluggableharness.tool.v1.ToolCall call = 1; - oneof outcome { - // The call's successful terminal result. - pluggableharness.tool.v1.ToolResult result = 2; - // The call's failed terminal result. - pluggableharness.tool.v1.ToolError error = 3; - } -} - -// PostApplyPayload fires once a turn's whole Plan has finished applying — -// every item has reached a terminal ApplyOutcome -// (pluggableharness.plan.v1.ApplyResult.ApplyOutcome). Reuses -// plan.v1.ApplyResult rather than defining its own per-item outcome shape, -// so the post-apply hook's subject and the EVENT_KIND_APPLY event (the -// forthcoming event.v1 package) are the exact same message. Observe-only — -// applying has already happened by the time this fires, so there is -// nothing left to transform. -message PostApplyPayload { - // The turn's per-item apply outcomes. MUST be set. - pluggableharness.plan.v1.ApplyResult apply = 1; -} - -// SessionEndPayload fires once, when a session reaches a terminal -// SessionStatus. No field here is transform-mutable — a session's outcome -// is already final by the time this fires. -message SessionEndPayload { - // The session that ended. MUST be set. Immutable. - string session_id = 1; - // The session's terminal status. MUST be set (never - // SESSION_STATUS_RUNNING). Immutable. - pluggableharness.session.v1.SessionStatus status = 2; -} - -// DispatchHookRequest is one hook-point firing delivered to one -// subscriber. -message DispatchHookRequest { - // The payload for this firing. MUST be set; the set oneof variant - // implicitly identifies the hook point. - HookPayload payload = 1; - // The operator-configured mode this subscription runs under - // (agent.hcl's hook{} block). MUST be set — tells the subscriber which - // of DispatchHookResponse's three outcome shapes is expected back. - HookMode mode = 2; - // Disambiguates a plugin declaring more than one hook{} block at the - // same HookPoint. Absent when a plugin has exactly one subscription at - // this point. - optional string subscription_id = 3; -} - -// DispatchHookResponse carries a subscriber's outcome, shaped by the -// HookMode the request declared. The kernel MUST reject (surfacing a -// hook_error, per agent-loop/hook-dispatch.md#subscriber-error-handling) -// a response whose oneof variant doesn't match the request's declared -// mode — HOOK_ERROR_CATEGORY_INVALID_RESPONSE. -message DispatchHookResponse { - oneof outcome { - // The subscriber's acknowledgment, for a HOOK_MODE_OBSERVE request. - ObserveAck observe = 1; - // The subscriber's modified payload, for a HOOK_MODE_TRANSFORM - // request. - TransformResult transform = 2; - // The subscriber's allow/deny verdict, for a HOOK_MODE_VETO request. - VetoResult veto = 3; - } - - // ObserveAck is empty. The kernel discards it (and any payload an - // observe subscriber mistakenly returns) unconditionally — observe mode - // can never alter the payload, per - // agent-loop/hook-dispatch.md#subscriber-error-handling. - message ObserveAck {} - - // TransformResult carries a transform subscriber's modified payload. - message TransformResult { - // MUST be the same oneof variant as the request's payload. MUST only - // mutate the fields this hook point documents as transform-mutable - // (see each *Payload message's own comment) — the kernel MUST reject - // a response that changes an immutable field or the variant itself, - // per agent-loop/hook-dispatch.md#subscriber-error-handling. - HookPayload payload = 1; - } - - // VetoResult carries a veto subscriber's allow/deny verdict. - message VetoResult { - // MUST be set. HOOK_DECISION_UNSPECIFIED is treated as an invalid - // response (HOOK_ERROR_CATEGORY_INVALID_RESPONSE), not as an implicit - // deny — the fail-closed behavior for a genuinely absent/erroring - // response is handled at the gRPC-status level, not by this enum's - // zero value. - HookDecision decision = 1; - } -} - -// HookDecision is a veto subscriber's coarse allow/deny verdict over a -// whole HookPayload. Deliberately distinct from -// pluggableharness.plan.v1.PlanDecision — that enum is per-plan-item -// and carries PENDING/ASK, which are meaningless for a hook-level veto; -// the two enums MUST NOT be merged, per .claude/rules/proto.md's -// no-untyped-overload discipline (see also ToolErrorCategory vs -// ModelErrorCategory for the same non-merge precedent). -enum HookDecision { - // Zero value. Never valid as a deliberately-chosen verdict; its - // presence on the wire means a caller forgot to set the field. - HOOK_DECISION_UNSPECIFIED = 0; - // The dispatch chain proceeds normally. - HOOK_DECISION_ALLOW = 1; - // The kernel MUST NOT let the vetoed action proceed. What "the action" - // means is point-specific: at HOOK_POINT_PLAN_READY it means the whole - // plan (or, for the kernel-privileged policy subscriber, the - // per-item decisions policy itself produces directly — a third-party - // veto subscriber at plan-ready returns only this coarse ALLOW/DENY - // over the whole plan, per architecture.md §Policy — first-party, not a - // plugin category). - HOOK_DECISION_DENY = 2; -} - -// HookErrorCategory classifies why a hook dispatch to one subscriber -// failed. Deliberately its own enum, not reused from tool.v1 or model.v1 — -// per .claude/rules/proto.md, a category proto's error taxonomy is never -// merged with another category's even where the surrounding shape is -// parallel. -enum HookErrorCategory { - // Zero value. Never valid for a real error; its presence on the wire - // means a caller forgot to set the field. - HOOK_ERROR_CATEGORY_UNSPECIFIED = 0; - // A transform subscriber raised an error or returned a malformed - // response. Aborts the remainder of that hook's chain - // (agent-loop/hook-dispatch.md#subscriber-error-handling). - HOOK_ERROR_CATEGORY_TRANSFORM_FAILED = 1; - // A veto subscriber raised an error. Treated identically to an - // explicit deny — fail-closed - // (agent-loop/hook-dispatch.md#subscriber-error-handling). - HOOK_ERROR_CATEGORY_VETO_FAILED = 2; - // The subscriber exceeded its per-subscriber deadline - // (agent-loop/hook-dispatch.md#timeout-behavior). For a veto - // subscriber, resolved identically to HOOK_ERROR_CATEGORY_VETO_FAILED — - // fail-closed deny. - HOOK_ERROR_CATEGORY_TIMEOUT = 3; - // The subscriber's DispatchHookResponse didn't match what its declared - // HookMode requires — wrong oneof variant, or (for transform) a - // payload variant that doesn't match the request's, or a mutation to a - // field this hook point doesn't document as transform-mutable. - HOOK_ERROR_CATEGORY_INVALID_RESPONSE = 4; - // The subscriber's plugin subprocess died mid-dispatch (transport - // error, not a graceful error the plugin chose to return). MUST be - // kernel-synthesized only, mirroring - // tool/conformance.md#error-taxonomy's TOOL_ERROR_CATEGORY_PROCESS_CRASHED - // — a plugin process that crashes obviously cannot emit this itself. - HOOK_ERROR_CATEGORY_PROCESS_CRASHED = 5; - // Anything else. - HOOK_ERROR_CATEGORY_UNKNOWN = 6; -} - -// HookError is the structured detail describing one failed hook dispatch. -// Also the payload shape the kernel-synthesized EVENT_KIND_HOOK_ERROR -// event carries (kernel.v1.EventKind, state-backend.md §The kind enum) — -// the forthcoming event.v1 package's HookErrorEvent wraps this same -// message rather than redefining it. -message HookError { - // Which hook point the failing dispatch was for. MUST be set. - pluggableharness.common.v1.HookPoint point = 1; - // Which plugin build the failing subscriber was. MUST be set. - pluggableharness.common.v1.ProducerRef subscriber = 2; - // The HookMode the failing subscription was declared under. MUST be - // set. - HookMode mode = 3; - // Which category of failure this is. MUST be set. - HookErrorCategory category = 4; - // Human-readable detail, e.g. the raw subscriber error message. - string message = 5; -} diff --git a/api/pluggableharness/hook/v1/rpc_request.proto b/api/pluggableharness/hook/v1/rpc_request.proto new file mode 100644 index 0000000..f71d017 --- /dev/null +++ b/api/pluggableharness/hook/v1/rpc_request.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package pluggableharness.hook.v1; + +import "pluggableharness/hook/v1/events.proto"; +import "pluggableharness/hook/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/hook/proto/v1;hookv1"; + +// Request message for the hook-dispatch protocol's RPC. + +// DispatchHookRequest is one hook-point firing delivered to one +// subscriber. +message DispatchHookRequest { + // The payload for this firing. MUST be set; the set oneof variant + // implicitly identifies the hook point. + HookPayload payload = 1; + // The operator-configured mode this subscription runs under + // (agent.hcl's hook{} block). MUST be set — tells the subscriber which + // of DispatchHookResponse's three outcome shapes is expected back. + HookMode mode = 2; + // Disambiguates a plugin declaring more than one hook{} block at the + // same HookPoint. Absent when a plugin has exactly one subscription at + // this point. + optional string subscription_id = 3; +} diff --git a/api/pluggableharness/hook/v1/rpc_response.proto b/api/pluggableharness/hook/v1/rpc_response.proto new file mode 100644 index 0000000..72d3af8 --- /dev/null +++ b/api/pluggableharness/hook/v1/rpc_response.proto @@ -0,0 +1,53 @@ +syntax = "proto3"; + +package pluggableharness.hook.v1; + +import "pluggableharness/hook/v1/events.proto"; +import "pluggableharness/hook/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/hook/proto/v1;hookv1"; + +// Response message for the hook-dispatch protocol's RPC. + +// DispatchHookResponse carries a subscriber's outcome, shaped by the +// HookMode the request declared. The kernel MUST reject (surfacing a +// hook_error, per agent-loop/hook-dispatch.md#subscriber-error-handling) +// a response whose oneof variant doesn't match the request's declared +// mode — HOOK_ERROR_CATEGORY_INVALID_RESPONSE. +message DispatchHookResponse { + oneof outcome { + // The subscriber's acknowledgment, for a HOOK_MODE_OBSERVE request. + ObserveAck observe = 1; + // The subscriber's modified payload, for a HOOK_MODE_TRANSFORM + // request. + TransformResult transform = 2; + // The subscriber's allow/deny verdict, for a HOOK_MODE_VETO request. + VetoResult veto = 3; + } + + // ObserveAck is empty. The kernel discards it (and any payload an + // observe subscriber mistakenly returns) unconditionally — observe mode + // can never alter the payload, per + // agent-loop/hook-dispatch.md#subscriber-error-handling. + message ObserveAck {} + + // TransformResult carries a transform subscriber's modified payload. + message TransformResult { + // MUST be the same oneof variant as the request's payload. MUST only + // mutate the fields this hook point documents as transform-mutable + // (see each *Payload message's own comment) — the kernel MUST reject + // a response that changes an immutable field or the variant itself, + // per agent-loop/hook-dispatch.md#subscriber-error-handling. + HookPayload payload = 1; + } + + // VetoResult carries a veto subscriber's allow/deny verdict. + message VetoResult { + // MUST be set. HOOK_DECISION_UNSPECIFIED is treated as an invalid + // response (HOOK_ERROR_CATEGORY_INVALID_RESPONSE), not as an implicit + // deny — the fail-closed behavior for a genuinely absent/erroring + // response is handled at the gRPC-status level, not by this enum's + // zero value. + HookDecision decision = 1; + } +} diff --git a/api/pluggableharness/hook/v1/service.proto b/api/pluggableharness/hook/v1/service.proto new file mode 100644 index 0000000..2cec368 --- /dev/null +++ b/api/pluggableharness/hook/v1/service.proto @@ -0,0 +1,46 @@ +syntax = "proto3"; + +// Package pluggableharness.hook.v1 defines the hook-dispatch RPC surface +// described in agent-loop/hook-dispatch.md and architecture.md §Hook +// dispatch semantics: the wire contract the kernel uses to invoke any +// plugin (of any of the six categories) that declares a `hook{}` block in +// agent.hcl. This is deliberately one shared service rather than a +// per-category RPC — hashicorp/go-plugin (.claude/rules/plugin-runtime.md) +// muxes multiple gRPC services over one broker connection, so the kernel +// dials HookSubscriberService on the same subprocess that already serves +// that plugin's own category service. A plugin with no `hook{}` blocks in +// agent.hcl simply never has it called. +// +// context-assemble is deliberately absent from this surface's hook-point +// vocabulary (common.v1.HookPoint) — it stays on ContextService.Contribute +// (context/protocol.md#contribute-the-context-assemble-rpc; architecture.md +// §Hook dispatch semantics), which already carries the full accumulated +// ContextSection chain and doesn't need a second, competing dispatch path. +// This surface serves the other eight hook points only. +package pluggableharness.hook.v1; + +import "pluggableharness/hook/v1/rpc_request.proto"; +import "pluggableharness/hook/v1/rpc_response.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/hook/proto/v1;hookv1"; + +// HookSubscriberService is the hook-dispatch protocol described in +// agent-loop/hook-dispatch.md: the kernel invokes a subscribing plugin +// once per hook-point firing it's declared for, in agent.hcl declaration +// order alongside every other subscriber at that point +// (agent-loop/hook-dispatch.md#dispatch-order-and-payload-flow). Every +// plugin category MAY implement this service; whether the kernel ever +// dials it for a given plugin process is entirely a function of that +// plugin's own agent.hcl `hook{}` declarations, not its category. +service HookSubscriberService { + // DispatchHook delivers one hook-point firing to one subscriber. Unary, + // not streaming: one invocation is one request/one response + // (agent-loop/hook-dispatch.md#dispatch-order-and-payload-flow). Per- + // subscriber timeout is a ctx deadline the kernel sets + // (.claude/rules/grpc.md "Context and deadlines"; + // agent-loop/hook-dispatch.md#timeout-behavior), not a wire field on + // this request. Cancellation (the kernel closing the call because the + // turn is being aborted) is normal control flow, per + // .claude/rules/grpc.md — never logged as a failure. + rpc DispatchHook(DispatchHookRequest) returns (DispatchHookResponse); +} diff --git a/api/pluggableharness/hook/v1/types.proto b/api/pluggableharness/hook/v1/types.proto new file mode 100644 index 0000000..0272473 --- /dev/null +++ b/api/pluggableharness/hook/v1/types.proto @@ -0,0 +1,57 @@ +syntax = "proto3"; + +package pluggableharness.hook.v1; + +option go_package = "github.com/pluggableharness/agent/pkg/hook/proto/v1;hookv1"; + +// Enums shared by the hook-dispatch protocol's request and response shapes. + +// HookMode is the operator-declared subscription mode for one plugin's +// hook{} block, per architecture.md §Hook dispatch semantics and +// agent-loop/hook-dispatch.md#dispatch-order-and-payload-flow. Mode is +// per-subscription (agent.hcl-declared), not per-payload — it is not +// carried in HookPayload itself, only echoed on DispatchHookRequest so the +// subscriber knows which of DispatchHookResponse's three outcome shapes is +// expected of it. +enum HookMode { + // Zero value. Never valid on the wire; its presence means a caller + // forgot to set the field. + HOOK_MODE_UNSPECIFIED = 0; + // Read-only, fire-and-forget. A raised error or malformed response is + // logged and dispatch continues; an observe subscriber can never alter + // the payload or abort the chain + // (agent-loop/hook-dispatch.md#subscriber-error-handling). + HOOK_MODE_OBSERVE = 1; + // Sequential chain member: receives the prior stage's payload, returns + // a modified version of the same variant. An error or malformed + // response aborts the remainder of that hook's chain and surfaces a + // hook_error event (agent-loop/hook-dispatch.md#subscriber-error-handling). + HOOK_MODE_TRANSFORM = 2; + // Returns an explicit allow/deny verdict. An error or timeout is + // treated identically to an explicit deny — fail-closed + // (agent-loop/hook-dispatch.md#timeout-behavior). + HOOK_MODE_VETO = 3; +} + +// HookDecision is a veto subscriber's coarse allow/deny verdict over a +// whole HookPayload. Deliberately distinct from +// pluggableharness.plan.v1.PlanDecision — that enum is per-plan-item +// and carries PENDING/ASK, which are meaningless for a hook-level veto; +// the two enums MUST NOT be merged, per .claude/rules/proto.md's +// no-untyped-overload discipline (see also ToolErrorCategory vs +// ModelErrorCategory for the same non-merge precedent). +enum HookDecision { + // Zero value. Never valid as a deliberately-chosen verdict; its + // presence on the wire means a caller forgot to set the field. + HOOK_DECISION_UNSPECIFIED = 0; + // The dispatch chain proceeds normally. + HOOK_DECISION_ALLOW = 1; + // The kernel MUST NOT let the vetoed action proceed. What "the action" + // means is point-specific: at HOOK_POINT_PLAN_READY it means the whole + // plan (or, for the kernel-privileged policy subscriber, the + // per-item decisions policy itself produces directly — a third-party + // veto subscriber at plan-ready returns only this coarse ALLOW/DENY + // over the whole plan, per architecture.md §Policy — first-party, not a + // plugin category). + HOOK_DECISION_DENY = 2; +} diff --git a/api/pluggableharness/kernel/v1/events.proto b/api/pluggableharness/kernel/v1/events.proto new file mode 100644 index 0000000..3aa8a62 --- /dev/null +++ b/api/pluggableharness/kernel/v1/events.proto @@ -0,0 +1,72 @@ +syntax = "proto3"; + +package pluggableharness.kernel.v1; + +import "google/protobuf/timestamp.proto"; +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/kernel/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1"; + +// Occurrence-shaped messages streamed by the two server-streaming +// KernelCallbackService RPCs: BusEvent (Subscribe) and StoredEvent +// (ReadEvents). + +// BusEvent is one event delivered to a Subscribe stream. See +// kernel-callbacks.md's Subscribe and event-bus.md. +message BusEvent { + // The event's fully-resolved topic. See event-bus.md#topic-grammar. + string topic = 1; + + // The event payload, exactly as published. MAY be empty. Opaque to the + // kernel — see PublishRequest.payload (rpc_request.proto). + bytes payload = 2; + + // Identifies payload's shape. See PublishRequest.payload_type. + string payload_type = 3; + + // Versions payload_type. See PublishRequest.schema_version. + string schema_version = 4; + + // When the kernel received the Publish call this event fans out from. + // MUST be set. Display-only — this bus assigns no sequence number and + // makes no cross-subscriber ordering guarantee + // (event-bus.md#delivery-semantics). + google.protobuf.Timestamp time = 5; +} + +// StoredEvent is one persisted event, read back by ReadEvents. Mirrors +// state-backend.md's events table row. See kernel-callbacks.md's +// ReadEvents. +message StoredEvent { + // The row's ordering-authoritative sequence number. MUST be set. + // ReadEvents streams StoredEvents in ascending sequence order — never + // by time (.claude/rules/determinism.md). + int64 sequence = 1; + + // The stable, storage-independent event id. MUST be set. + string id = 2; + + // When this event occurred, wall-clock, display-only — never used to + // order anything (.claude/rules/determinism.md). MUST be set. + google.protobuf.Timestamp time = 3; + + // The event's kind. MUST be set. + EventKind kind = 4; + + // The plugin that originally Emit'd this event, read back from + // storage — unlike EmitRequest, which never carries a producer field, + // this is server-populated on write and simply returned here, not + // server-derived from the calling connection (the caller reading this + // back may be a different plugin than the one that emitted it). MUST + // be set. + pluggableharness.common.v1.ProducerRef producer = 5; + + // The schema version of `payload`. MUST be set. See + // EmitRequest.schema_version. + string schema_version = 6; + + // The event payload, exactly as the originating Emit call wrote it. + // Opaque to the kernel — see EmitRequest.payload. + bytes payload = 7; +} diff --git a/api/pluggableharness/kernel/v1/kernel.proto b/api/pluggableharness/kernel/v1/kernel.proto deleted file mode 100644 index ea64e80..0000000 --- a/api/pluggableharness/kernel/v1/kernel.proto +++ /dev/null @@ -1,298 +0,0 @@ -syntax = "proto3"; - -// Package pluggableharness.kernel.v1 defines the kernel-callback service described -// in specifications/kernel-callbacks.md (RunSession, CountTokens, Emit, -// Log) — the plugin-to-kernel calling direction every plugin category gets -// at handshake, the reverse of every other category's protocol in this -// series. Unlike a category plugin protocol, this service carries no -// GetCapabilities/Configure RPCs: it isn't something the kernel dials into -// a plugin, it's the connection every plugin subprocess is handed back to -// call into the kernel. -package pluggableharness.kernel.v1; - -import "pluggableharness/common/v1/common.proto"; -import "pluggableharness/content/v1/content.proto"; -import "pluggableharness/log/v1/log.proto"; -import "pluggableharness/model/v1/model.proto"; -import "pluggableharness/session/v1/session.proto"; - -option go_package = "github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1"; - -// KernelCallbackService is the plugin-to-kernel callback channel described -// in specifications/kernel-callbacks.md §1. hashicorp/go-plugin natively -// supports bidirectional plugins, and this is that mechanism: every plugin -// subprocess, for every category defined across this series, MUST be given -// a client connection to this service at handshake time, unconditionally. -// A plugin that never calls back simply never uses it, but the channel's -// presence is not gated on category — a context provider needing -// CountTokens is just as valid a caller as a tool provider needing -// RunSession. -service KernelCallbackService { - // RunSession dispatches a nested sub-agent session under a named - // agent.hcl profile. Full semantics — profile resolution, budget - // inheritance, visibility of intermediate turns — are defined in - // agent-loop.md §7 and are not repeated here; kernel-callbacks.md §1 - // gives this RPC's calling contract. - // - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "RunSessionResult", the exact name agent-loop.md §7.1 - // uses in its own data-type definition. Not a uniqueness violation: - // RunSessionResult is used by exactly this one RPC. - rpc RunSession(RunSessionRequest) returns (RunSessionResult); - - // CountTokens resolves the token-counting gap independently flagged by - // context.md §12, configuration.md §12, memory.md §13, and frontend.md - // §10: exactly one kernel-owned implementation, so that `tokens` figures - // produced by different providers stay mutually comparable and additive - // for configuration.md §6's budget-sum arithmetic. See - // kernel-callbacks.md §2 for the resolution algorithm and §3 for the - // single documented fallback formula. - // - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "CountTokensResult", the exact name kernel-callbacks.md - // §2 uses. Not a uniqueness violation: used by exactly this one RPC. - rpc CountTokens(CountTokensRequest) returns (CountTokensResult); - - // Emit is how a plugin persists anything into the session's state - // backend. The kernel is the state backend's sole writer - // (state-backend.md §3) — a plugin never opens or writes the sqlite file - // directly; it calls Emit and the kernel performs the actual write, - // assigning the ordering-authoritative sequence number and the stable - // event id itself. See kernel-callbacks.md §4. - // - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "EmitResult", the exact name kernel-callbacks.md §4 - // uses. Not a uniqueness violation: used by exactly this one RPC. - rpc Emit(EmitRequest) returns (EmitResult); - - // Log carries a plugin's own log output into the kernel's centralized - // logging, so it doesn't vanish into an unread subprocess stderr. - // Unlike Emit, a Log call is not tied to an active session — a plugin - // MAY call Log before any session exists (process startup, or from - // within Configure) or after one has ended (during shutdown). See - // kernel-callbacks.md §5. - // - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "LogResult", the exact name kernel-callbacks.md §5 - // uses. Not a uniqueness violation: used by exactly this one RPC. - rpc Log(LogRequest) returns (LogResult); -} - -// RunSessionRequest names the sub-agent profile to dispatch and carries -// the inherited, only-shrinking resource budgets the child session is -// bound by. See agent-loop.md §7. -message RunSessionRequest { - // Named sub-agent profile from agent.hcl to run this session under. - // MUST be set. - string profile = 1; - - // The prompt to run the sub-agent session with. - string prompt = 2; - - // The calling session's id. MUST be set to the id of the session making - // this RunSession call, establishing the parent/child relationship the - // state backend and replay model rely on. - string parent_session_id = 3; - - // Remaining sub-agent nesting depth available to the child session. - // MUST be set. An inherited, only-shrinking budget computed by the - // kernel per configuration.md §8.4 / agent-loop.md §7.5 — the child is - // never able to widen it, only spend down what it was given. - int32 remaining_depth = 4; - - // Remaining cost budget, in USD, available to the child session. MUST - // be set. Same inherited, only-shrinking shape as remaining_depth, - // computed per agent-loop.md §3.1. - double remaining_cost_budget_usd = 5; - - // The set of providers the child session is scoped to, resolved from - // the named profile's declared tool set. A caller MAY narrow this - // further per-call but MUST NOT widen it beyond what the profile - // declares. - repeated pluggableharness.common.v1.ProviderRef scoped_providers = 6; -} - -// RunSessionResult carries the child session's outcome back to the -// calling plugin once the child has reached a terminal state. -message RunSessionResult { - // The id of the child session that was created and run. - string session_id = 1; - - // The child session's final message — the only thing that crosses the - // session boundary back to the parent turn. Intermediate turns - // produced by the child are never visible to the parent's model - // context (agent-loop.md §7.2), though they remain queryable in the - // state backend for replay and audit. - pluggableharness.content.v1.Message final_message = 2; - - // The child session's terminal lifecycle status. Never - // SESSION_STATUS_RUNNING — a RunSession call only returns once the - // child session has reached a terminal state. - pluggableharness.session.v1.SessionStatus status = 3; - - // The child session's aggregate cost, in USD, summed across every turn - // it ran, including any of its own descendant sub-agent sessions. MUST - // be set. Deliberately a flat field, not a - // pluggableharness.model.v1.Usage-shaped reference — this is a - // whole-session rollup (state-backend.md's cost_ledger SUM, per - // .claude/rules/determinism.md's "Cost and budget rollup"), a different - // shape from one completion call's per-call Usage. Lets an orchestrator - // plugin do budget-aware fan-out (spend-check a child's outcome before - // deciding whether to spawn another) without needing to separately sum - // the child's own event history itself. - double total_cost_usd = 4; - - // The child session's aggregate input token count, summed across every - // turn it ran, including descendants. MUST be set. Same flat, - // aggregate-not-per-call rationale as total_cost_usd. - int64 total_input_tokens = 5; - - // The child session's aggregate output token count, summed across - // every turn it ran, including descendants. MUST be set. Same flat, - // aggregate-not-per-call rationale as total_cost_usd. - int64 total_output_tokens = 6; -} - -// CountTokensRequest asks the kernel to count tokens for a block of -// content, optionally against a specific model's tokenizer. See -// kernel-callbacks.md §2. -message CountTokensRequest { - // The content to count. MUST be set. Text-only in v1, matching the - // content-type constraint context.md and memory.md already impose. - repeated pluggableharness.content.v1.ContentBlock content = 1; - - // The model whose tokenizer should be preferred, if that model - // provider implements the optional CountTokens RPC (model.md §2.1). - // MAY be omitted, in which case the kernel's fallback heuristic - // (kernel-callbacks.md §3) is used. - optional pluggableharness.model.v1.ModelRef model_ref = 2; -} - -// CountTokensResult carries the resolved token count and whether it came -// from a real vendor tokenizer or the kernel's fallback heuristic. -message CountTokensResult { - // The resolved token count. - int64 count = 1; - - // MUST be set. True if a real vendor tokenizer produced this count - // (that model provider's own optional CountTokens RPC); false if the - // kernel's single documented fallback heuristic did - // (kernel-callbacks.md §3: ceil(total_utf8_byte_length(text)/4)). - bool exact = 2; -} - -// EventKind identifies the shape of an EmitRequest's opaque payload. This -// is state-backend.md §5's authoritative kind enum, restated here only -// because it is the wire-level type Emit actually carries — -// state-backend.md §5 remains authoritative if the two ever need -// reconciling again. Usage/cost figures, Render() output, and -// session_start/session_end deliberately do NOT get their own EventKind; -// see state-backend.md §5 for why. -enum EventKind { - // Zero value. Never valid on the wire; its presence means a caller - // forgot to set the field. - EVENT_KIND_UNSPECIFIED = 0; - - // A completed model turn's canonical message (model.md §5). Usage - // and cost figures live inside this payload, not as separate fields or - // a separate EventKind (state-backend.md §5). - EVENT_KIND_MESSAGE = 1; - - // A tool invocation's call. - EVENT_KIND_TOOL_CALL = 2; - - // A tool invocation's result. - EVENT_KIND_TOOL_RESULT = 3; - - // A built Plan, prior to plan-ready dispatch. - EVENT_KIND_PLAN = 4; - - // The outcome of applying a Plan. - EVENT_KIND_APPLY = 5; - - // A context provider's Contribute output, or a memory provider's - // Recall output after kernel translation (memory.md §6). - EVENT_KIND_CONTEXT_CONTRIBUTION = 6; - - // A memory provider's write of a new record. - EVENT_KIND_MEMORY_WRITE = 7; - - // A memory provider's update of an existing record. - EVENT_KIND_MEMORY_UPDATE = 8; - - // A memory provider's deletion of an existing record. - EVENT_KIND_MEMORY_DELETE = 9; - - // Kernel-synthesized when a transform or veto hook subscriber fails - // (agent-loop/hook-dispatch.md#subscriber-error-handling) — never - // emitted by a plugin's own Emit call, only by the kernel itself - // dispatching a hook. Payload shape is - // pluggableharness.hook.v1.HookError, wrapped by the forthcoming - // event.v1 package's HookErrorEvent (state-backend.md §5). - EVENT_KIND_HOOK_ERROR = 10; -} - -// EmitRequest asks the kernel to persist one event into the calling -// session's state backend. See kernel-callbacks.md §4. -message EmitRequest { - // The calling session's id. MUST be set. The kernel MUST reject an - // Emit naming any session other than the one the calling plugin was - // actually invoked for. - string session_id = 1; - - // The kind of event being emitted. MUST be set. - EventKind kind = 2; - - // Versions the shape of `payload`, so a future kernel can still - // interpret an old event correctly — the "supersedes" mechanism - // described in docs/specifications/architecture.md. MUST be set. - string schema_version = 3; - - // The event payload. MUST be set. Opaque to the kernel by design - // (state-backend.md §4.1: "kernel never inspects this"); its structure - // is defined by whichever spec owns this EventKind. This is the one - // deliberate opaque-bytes carve-out in this file — every other field - // here is strongly typed. - bytes payload = 4; - - // Note: producer_category/producer_name/producer_version - // (state-backend.md §4.1's other envelope fields) are deliberately NOT - // fields on this message. The kernel already knows which plugin is - // calling — a property of the already-authenticated callback - // connection established at handshake — and fills them in - // server-side. A plugin cannot declare a producer identity other than - // its own; there is no field here to spoof. -} - -// EmitResult carries the identifiers the kernel assigned to a persisted -// event. -message EmitResult { - // The assigned, storage-independent event id. - string id = 1; - - // The assigned, ordering-authoritative sequence number. - int64 sequence = 2; -} - -// LogRequest carries one structured log entry from a plugin to the -// kernel. See kernel-callbacks.md §5. -message LogRequest { - // The session this log entry is attributable to. MAY be omitted — - // unlike EmitRequest.session_id, this is not mandatory, since logging - // can legitimately happen outside any session context (plugin startup, - // Configure-time, shutdown). - optional string session_id = 1; - - // The log entry itself. MUST be set. - pluggableharness.log.v1.LogEntry entry = 2; - - // Note: producer_category/producer_name/producer_version are - // deliberately NOT fields on this message, for the same reason - // EmitRequest omits them — the kernel derives producer identity - // server-side from the already-authenticated callback connection - // established at handshake. There is no field here to spoof. -} - -// LogResult is empty: a Log call either succeeds or the RPC itself -// returns a gRPC error status. There is nothing else to report back. -message LogResult {} diff --git a/api/pluggableharness/kernel/v1/rpc_request.proto b/api/pluggableharness/kernel/v1/rpc_request.proto new file mode 100644 index 0000000..ab0aabf --- /dev/null +++ b/api/pluggableharness/kernel/v1/rpc_request.proto @@ -0,0 +1,234 @@ +syntax = "proto3"; + +package pluggableharness.kernel.v1; + +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/content/v1/types.proto"; +import "pluggableharness/kernel/v1/types.proto"; +import "pluggableharness/log/v1/types.proto"; +import "pluggableharness/metric/v1/types.proto"; +import "pluggableharness/model/v1/types.proto"; +import "pluggableharness/trace/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1"; + +// Request messages for every KernelCallbackService RPC. + +// RunSessionRequest names the sub-agent profile to dispatch and carries +// the inherited, only-shrinking resource budgets the child session is +// bound by. See agent-loop.md §7. +message RunSessionRequest { + // Named sub-agent profile from agent.hcl to run this session under. + // MUST be set. + string profile = 1; + + // The prompt to run the sub-agent session with. + string prompt = 2; + + // The calling session's id. MUST be set to the id of the session making + // this RunSession call, establishing the parent/child relationship the + // state backend and replay model rely on. + string parent_session_id = 3; + + // Remaining sub-agent nesting depth available to the child session. + // MUST be set. An inherited, only-shrinking budget computed by the + // kernel per configuration.md §8.4 / agent-loop.md §7.5 — the child is + // never able to widen it, only spend down what it was given. + int32 remaining_depth = 4; + + // Remaining cost budget, in USD, available to the child session. MUST + // be set. Same inherited, only-shrinking shape as remaining_depth, + // computed per agent-loop.md §3.1. + double remaining_cost_budget_usd = 5; + + // The set of providers the child session is scoped to, resolved from + // the named profile's declared tool set. A caller MAY narrow this + // further per-call but MUST NOT widen it beyond what the profile + // declares. + repeated pluggableharness.common.v1.ProviderRef scoped_providers = 6; +} + +// CountTokensRequest asks the kernel to count tokens for a block of +// content, optionally against a specific model's tokenizer. See +// kernel-callbacks.md §2. +message CountTokensRequest { + // The content to count. MUST be set. Text-only in v1, matching the + // content-type constraint context.md and memory.md already impose. + repeated pluggableharness.content.v1.ContentBlock content = 1; + + // The model whose tokenizer should be preferred, if that model + // provider implements the optional CountTokens RPC (model.md §2.1). + // MAY be omitted, in which case the kernel's fallback heuristic + // (kernel-callbacks.md §3) is used. + optional pluggableharness.model.v1.ModelRef model_ref = 2; +} + +// EmitRequest asks the kernel to persist one event into the calling +// session's state backend. See kernel-callbacks.md §4. +message EmitRequest { + // The calling session's id. MUST be set. The kernel MUST reject an + // Emit naming any session other than the one the calling plugin was + // actually invoked for. + string session_id = 1; + + // The kind of event being emitted. MUST be set. + EventKind kind = 2; + + // Versions the shape of `payload`, so a future kernel can still + // interpret an old event correctly — the "supersedes" mechanism + // described in docs/specifications/architecture.md. MUST be set. + string schema_version = 3; + + // The event payload. MUST be set. Opaque to the kernel by design + // (state-backend.md §4.1: "kernel never inspects this"); its structure + // is defined by whichever spec owns this EventKind. One of two + // deliberate opaque-bytes carve-outs in this file — see PublishRequest + // below for the other — every other field in either message is + // strongly typed. + bytes payload = 4; + + // Note: producer_category/producer_name/producer_version + // (state-backend.md §4.1's other envelope fields) are deliberately NOT + // fields on this message. The kernel already knows which plugin is + // calling — a property of the already-authenticated callback + // connection established at handshake — and fills them in + // server-side. A plugin cannot declare a producer identity other than + // its own; there is no field here to spoof. +} + +// LogRequest carries a batch of structured log entries from a plugin to +// the kernel. See kernel-callbacks.md §5. Batched rather than one entry +// per call: a plugin logging at TRACE would otherwise pay one unary +// round-trip per line. +message LogRequest { + // The session this log entry is attributable to. MAY be omitted — + // unlike EmitRequest.session_id, this is not mandatory, since logging + // can legitimately happen outside any session context (plugin startup, + // Configure-time, shutdown). + optional string session_id = 1; + + reserved 2; + reserved "entry"; + + // The batch of log entries, in the order the plugin produced them. + // MUST be non-empty. A malformed entry within an otherwise-valid batch + // is skipped and warned about individually, not treated as failing the + // whole call — see kernel-callbacks.md §5. + repeated pluggableharness.log.v1.LogEntry entries = 3; + + // Note: producer_category/producer_name/producer_version are + // deliberately NOT fields on this message, for the same reason + // EmitRequest omits them — the kernel derives producer identity + // server-side from the already-authenticated callback connection + // established at handshake. There is no field here to spoof. +} + +// ExportSpansRequest relays a batch of a plugin's own completed trace +// spans to the kernel for forwarding to the operator's configured +// collector. See kernel-callbacks.md's ExportSpans and +// observability.md#the-relay-model. +message ExportSpansRequest { + // The session this batch is attributable to, when any. MAY be + // omitted — same session-optional rule as LogRequest.session_id, since + // a plugin may produce spans outside any session context. + optional string session_id = 1; + + // The batch of completed spans. MUST be non-empty. The kernel MUST NOT + // alter any span's identity or timing fields before relaying it. + repeated pluggableharness.trace.v1.Span spans = 2; +} + +// RecordMetricsRequest relays a batch of metric observations. See +// kernel-callbacks.md's RecordMetrics and +// observability.md#the-tracing-metrics-asymmetry for why this is not a +// transparent relay the way ExportSpansRequest is. +message RecordMetricsRequest { + // The session this batch is attributable to, when any. MAY be + // omitted — same rule as LogRequest.session_id. + optional string session_id = 1; + + // The batch of metric observations. MUST be non-empty. + repeated pluggableharness.metric.v1.MetricRecord metrics = 2; +} + +// GetTelemetryConfigRequest asks the kernel whether tracing/metrics/logs +// are enabled and at what level/ratio. See kernel-callbacks.md's +// GetTelemetryConfig. Empty: the caller's identity comes from the +// callback connection, never a request field. +message GetTelemetryConfigRequest {} + +// GetConfigRequest asks the kernel for the calling plugin's own resolved +// agent.hcl configuration. See kernel-callbacks.md's GetConfig. Empty: +// the caller's identity comes from the callback connection, never a +// request field. +message GetConfigRequest {} + +// PublishRequest emits one event onto the event bus. See +// kernel-callbacks.md's Publish and event-bus.md. +message PublishRequest { + // A single dot-free, wildcard-free segment naming this occurrence + // within the plugin's own namespace, e.g. "file_changed". MUST be set. + // The kernel MUST reject a value containing "." or "*". + string event_type = 1; + + // The event payload. MAY be empty. Opaque to the kernel by design — + // the second of two deliberate opaque-bytes carve-outs in this + // package (see EmitRequest.payload above); a third-party plugin's own + // event shape can't be named by this proto ahead of time. + bytes payload = 2; + + // Identifies payload's shape for a subscriber: a fully-qualified proto + // message name (preferred) or a media type. MUST be set. + string payload_type = 3; + + // Versions payload_type the same way EmitRequest.schema_version + // versions Emit's payload. MUST be set. + string schema_version = 4; + + // Note: no topic field exists here on purpose — the kernel constructs + // the fully-resolved topic ("plugin.{category}.{name}.{event_type}") + // from the authenticated callback connection's producer identity, the + // same anti-spoof rule EmitRequest/LogRequest already apply. See + // event-bus.md#topic-grammar. +} + +// SubscribeRequest opens a server-streaming subscription to the event +// bus. See kernel-callbacks.md's Subscribe and event-bus.md#filter-grammar. +message SubscribeRequest { + // The topics to receive events for. MUST be non-empty. Each entry is + // either an exact topic or a topic prefix ending in "*" + // (event-bus.md#filter-grammar). No other wildcard form is valid in + // v1. + repeated string topic_filters = 1; +} + +// ReadEventsRequest asks the kernel to read back the calling plugin's own +// session's persisted event log, ordered by sequence. See +// kernel-callbacks.md's ReadEvents. +message ReadEventsRequest { + // The calling session's id. MUST be set. Same one-session-only rule as + // EmitRequest.session_id — the kernel MUST reject a call naming any + // other session. + string session_id = 1; + + // Restricts the stream to these kinds. MAY be empty, meaning every + // kind. + repeated EventKind kinds = 2; + + // Resume point: only events with sequence >= this value are streamed. + // MAY be omitted, meaning from the start of the session's log. + optional int64 from_sequence = 3; + + // Caps the number of events streamed. MAY be omitted, meaning no + // limit. + optional int32 limit = 4; +} + +// GetSessionRequest asks the kernel for the calling plugin's own +// session's metadata and live budget rollups. See kernel-callbacks.md's +// GetSession. +message GetSessionRequest { + // The calling session's id. MUST be set. Same one-session-only rule as + // EmitRequest.session_id. + string session_id = 1; +} diff --git a/api/pluggableharness/kernel/v1/rpc_response.proto b/api/pluggableharness/kernel/v1/rpc_response.proto new file mode 100644 index 0000000..c7e7fcc --- /dev/null +++ b/api/pluggableharness/kernel/v1/rpc_response.proto @@ -0,0 +1,149 @@ +syntax = "proto3"; + +package pluggableharness.kernel.v1; + +import "google/protobuf/struct.proto"; +import "pluggableharness/content/v1/types.proto"; +import "pluggableharness/log/v1/types.proto"; +import "pluggableharness/session/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1"; + +// Response messages for every KernelCallbackService RPC. + +// RunSessionResult carries the child session's outcome back to the +// calling plugin once the child has reached a terminal state. +message RunSessionResult { + // The id of the child session that was created and run. + string session_id = 1; + + // The child session's final message — the only thing that crosses the + // session boundary back to the parent turn. Intermediate turns + // produced by the child are never visible to the parent's model + // context (agent-loop.md §7.2), though they remain queryable in the + // state backend for replay and audit. + pluggableharness.content.v1.Message final_message = 2; + + // The child session's terminal lifecycle status. Never + // SESSION_STATUS_RUNNING — a RunSession call only returns once the + // child session has reached a terminal state. + pluggableharness.session.v1.SessionStatus status = 3; + + // The child session's aggregate cost, in USD, summed across every turn + // it ran, including any of its own descendant sub-agent sessions. MUST + // be set. Deliberately a flat field, not a + // pluggableharness.model.v1.Usage-shaped reference — this is a + // whole-session rollup (state-backend.md's cost_ledger SUM, per + // .claude/rules/determinism.md's "Cost and budget rollup"), a different + // shape from one completion call's per-call Usage. Lets an orchestrator + // plugin do budget-aware fan-out (spend-check a child's outcome before + // deciding whether to spawn another) without needing to separately sum + // the child's own event history itself. + double total_cost_usd = 4; + + // The child session's aggregate input token count, summed across every + // turn it ran, including descendants. MUST be set. Same flat, + // aggregate-not-per-call rationale as total_cost_usd. + int64 total_input_tokens = 5; + + // The child session's aggregate output token count, summed across + // every turn it ran, including descendants. MUST be set. Same flat, + // aggregate-not-per-call rationale as total_cost_usd. + int64 total_output_tokens = 6; +} + +// CountTokensResult carries the resolved token count and whether it came +// from a real vendor tokenizer or the kernel's fallback heuristic. +message CountTokensResult { + // The resolved token count. + int64 count = 1; + + // MUST be set. True if a real vendor tokenizer produced this count + // (that model provider's own optional CountTokens RPC); false if the + // kernel's single documented fallback heuristic did + // (kernel-callbacks.md §3: ceil(total_utf8_byte_length(text)/4)). + bool exact = 2; +} + +// EmitResult carries the identifiers the kernel assigned to a persisted +// event. +message EmitResult { + // The assigned, storage-independent event id. + string id = 1; + + // The assigned, ordering-authoritative sequence number. + int64 sequence = 2; +} + +// LogResult is empty: a Log call either succeeds or the RPC itself +// returns a gRPC error status. There is nothing else to report back. +message LogResult {} + +// ExportSpansResult is empty, the same shape as LogResult and for the +// same reason. +message ExportSpansResult {} + +// RecordMetricsResult is empty, the same shape as LogResult and for the +// same reason. +message RecordMetricsResult {} + +// GetTelemetryConfigResult carries the operator's current tracing/ +// metrics/logs configuration. See kernel-callbacks.md's +// GetTelemetryConfig. +message GetTelemetryConfigResult { + // Whether trace export is on. MUST be set. + bool traces_enabled = 1; + + // Whether metrics export is on. MUST be set. + bool metrics_enabled = 2; + + // Whether log export is on. MUST be set. + bool logs_enabled = 3; + + // The floor below which a Log entry is accepted but immediately + // discarded kernel-side. MUST be set. See kernel-callbacks.md's Log + // section. + pluggableharness.log.v1.LogLevel log_level = 4; + + // The configured ParentBased(TraceIDRatioBased) sampler ratio. MUST be + // set; meaningful only when traces_enabled. + double sampling_ratio = 5; +} + +// GetConfigResult carries the calling plugin's own already-decoded +// agent.hcl configuration. See kernel-callbacks.md's GetConfig. +message GetConfigResult { + // The plugin's resolved config, identical in shape to what its own + // ConfigureRequest.config carried at Configure time. MUST be set. + // Secrets are already resolved through the schema-to-cty bridge — see + // kernel-callbacks.md's GetConfig for the MUST NOT-echo rule this + // implies. + google.protobuf.Struct config = 1; +} + +// PublishResult carries the fully-resolved topic an event was published +// on. See kernel-callbacks.md's Publish. +message PublishResult { + // The topic this event was published on: + // "plugin.{category}.{name}.{event_type}". See + // event-bus.md#topic-grammar. + string topic = 1; +} + +// GetSessionResult carries the calling plugin's own session's metadata +// plus its live, in-memory budget rollups. See kernel-callbacks.md's +// GetSession. +message GetSessionResult { + // The session's persisted metadata and cost rollup. MUST be set. + pluggableharness.session.v1.SessionInfo info = 1; + + // The session's remaining sub-agent nesting depth. MUST be set. Live, + // in-memory kernel state — never persisted (state-backend.md's + // live-vs-post-hoc distinction) — not a value read back from the state + // backend the way info.cost_usd is. + int32 remaining_depth = 2; + + // The session's remaining cost budget, in USD. MUST be set. Same + // live, in-memory rationale as remaining_depth. + double remaining_cost_budget_usd = 3; +} diff --git a/api/pluggableharness/kernel/v1/service.proto b/api/pluggableharness/kernel/v1/service.proto new file mode 100644 index 0000000..e53a07d --- /dev/null +++ b/api/pluggableharness/kernel/v1/service.proto @@ -0,0 +1,152 @@ +syntax = "proto3"; + +// Package pluggableharness.kernel.v1 defines the kernel-callback service described +// in specifications/kernel-callbacks.md (RunSession, CountTokens, Emit, +// Log, ExportSpans, RecordMetrics, GetTelemetryConfig, GetConfig, Publish, +// Subscribe, ReadEvents, GetSession) — the plugin-to-kernel calling +// direction every plugin category gets at handshake, the reverse of every +// other category's protocol in this series. Unlike a category plugin +// protocol, this service carries no GetCapabilities/Configure RPCs: it +// isn't something the kernel dials into a plugin, it's the connection +// every plugin subprocess is handed back to call into the kernel. +package pluggableharness.kernel.v1; + +import "pluggableharness/kernel/v1/events.proto"; +import "pluggableharness/kernel/v1/rpc_request.proto"; +import "pluggableharness/kernel/v1/rpc_response.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1"; + +// KernelCallbackService is the plugin-to-kernel callback channel described +// in specifications/kernel-callbacks.md §1. hashicorp/go-plugin natively +// supports bidirectional plugins, and this is that mechanism: every plugin +// subprocess, for every category defined across this series, MUST be given +// a client connection to this service at handshake time, unconditionally. +// A plugin that never calls back simply never uses it, but the channel's +// presence is not gated on category — a context provider needing +// CountTokens is just as valid a caller as a tool provider needing +// RunSession. +service KernelCallbackService { + // RunSession dispatches a nested sub-agent session under a named + // agent.hcl profile. Full semantics — profile resolution, budget + // inheritance, visibility of intermediate turns — are defined in + // agent-loop.md §7 and are not repeated here; kernel-callbacks.md §1 + // gives this RPC's calling contract. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "RunSessionResult", the exact name agent-loop.md §7.1 + // uses in its own data-type definition. Not a uniqueness violation: + // RunSessionResult is used by exactly this one RPC. + rpc RunSession(RunSessionRequest) returns (RunSessionResult); + + // CountTokens resolves the token-counting gap independently flagged by + // context.md §12, configuration.md §12, memory.md §13, and frontend.md + // §10: exactly one kernel-owned implementation, so that `tokens` figures + // produced by different providers stay mutually comparable and additive + // for configuration.md §6's budget-sum arithmetic. See + // kernel-callbacks.md §2 for the resolution algorithm and §3 for the + // single documented fallback formula. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "CountTokensResult", the exact name kernel-callbacks.md + // §2 uses. Not a uniqueness violation: used by exactly this one RPC. + rpc CountTokens(CountTokensRequest) returns (CountTokensResult); + + // Emit is how a plugin persists anything into the session's state + // backend. The kernel is the state backend's sole writer + // (state-backend.md §3) — a plugin never opens or writes the sqlite file + // directly; it calls Emit and the kernel performs the actual write, + // assigning the ordering-authoritative sequence number and the stable + // event id itself. See kernel-callbacks.md §4. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "EmitResult", the exact name kernel-callbacks.md §4 + // uses. Not a uniqueness violation: used by exactly this one RPC. + rpc Emit(EmitRequest) returns (EmitResult); + + // Log carries a plugin's own log output into the kernel's centralized + // logging, so it doesn't vanish into an unread subprocess stderr. + // Unlike Emit, a Log call is not tied to an active session — a plugin + // MAY call Log before any session exists (process startup, or from + // within Configure) or after one has ended (during shutdown). See + // kernel-callbacks.md §5. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "LogResult", the exact name kernel-callbacks.md §5 + // uses. Not a uniqueness violation: used by exactly this one RPC. + rpc Log(LogRequest) returns (LogResult); + + // ExportSpans relays a batch of a plugin's own completed trace spans to + // the kernel, which forwards them to the operator's configured + // collector essentially unchanged. This reverses an earlier + // direct-per-process-OTLP-export design — see + // specifications/observability.md#the-relay-model for why. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "ExportSpansResult", used by exactly this one RPC. + rpc ExportSpans(ExportSpansRequest) returns (ExportSpansResult); + + // RecordMetrics relays a batch of metric observations. Unlike + // ExportSpans, this is not a transparent relay: the kernel records each + // observation against its own instrument and bounds the attribute key + // set before it reaches any exporter. See + // specifications/observability.md#the-tracing-metrics-asymmetry. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "RecordMetricsResult", used by exactly this one RPC. + rpc RecordMetrics(RecordMetricsRequest) returns (RecordMetricsResult); + + // GetTelemetryConfig answers whether tracing/metrics/logs are enabled + // and at what level/ratio, so a plugin doesn't have to guess from its + // own environment. A plugin SHOULD call this once at startup and cache + // the result — see specifications/observability.md#gettelemetryconfig-caching. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "GetTelemetryConfigResult", used by exactly this one + // RPC. + rpc GetTelemetryConfig(GetTelemetryConfigRequest) returns (GetTelemetryConfigResult); + + // GetConfig returns the calling plugin's own already-decoded agent.hcl + // configuration — the same shape Configure received. See + // kernel-callbacks.md's GetConfig for the secret-echo MUST NOT rule this + // implies. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "GetConfigResult", used by exactly this one RPC. + rpc GetConfig(GetConfigRequest) returns (GetConfigResult); + + // Publish emits one event onto the ephemeral, best-effort, cross-plugin + // event bus, distinct from Emit's durable per-session log and from + // hook dispatch's synchronous, agent.hcl-declared subscriber chain. See + // specifications/event-bus.md. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "PublishResult", used by exactly this one RPC. + rpc Publish(PublishRequest) returns (PublishResult); + + // Subscribe opens a server-streaming subscription to the event bus, + // filtered by topic. See specifications/event-bus.md#filter-grammar and + // #backpressure for why the kernel may unilaterally close this stream. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Stream element type is "BusEvent", naming the streamed domain concept + // rather than the RPC, the same convention model.md §4's StreamEvent + // and widget.md's WidgetUpdate already use. + rpc Subscribe(SubscribeRequest) returns (stream BusEvent); + + // ReadEvents reads back the calling plugin's own session's persisted + // event log, ordered by sequence — never by wall-clock time + // (.claude/rules/determinism.md). + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Stream element type is "StoredEvent", the same "name the domain + // concept" convention as Subscribe's BusEvent above. + rpc ReadEvents(ReadEventsRequest) returns (stream StoredEvent); + + // GetSession returns the calling plugin's own session's metadata plus + // its live, in-memory budget rollups. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "GetSessionResult", used by exactly this one RPC. + rpc GetSession(GetSessionRequest) returns (GetSessionResult); +} diff --git a/api/pluggableharness/kernel/v1/types.proto b/api/pluggableharness/kernel/v1/types.proto new file mode 100644 index 0000000..d7e8ea7 --- /dev/null +++ b/api/pluggableharness/kernel/v1/types.proto @@ -0,0 +1,58 @@ +syntax = "proto3"; + +package pluggableharness.kernel.v1; + +option go_package = "github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1"; + +// Domain types shared across KernelCallbackService RPCs. + +// EventKind identifies the shape of an EmitRequest's opaque payload. This +// is state-backend.md §5's authoritative kind enum, restated here only +// because it is the wire-level type Emit actually carries — +// state-backend.md §5 remains authoritative if the two ever need +// reconciling again. Usage/cost figures, Render() output, and +// session_start/session_end deliberately do NOT get their own EventKind; +// see state-backend.md §5 for why. +enum EventKind { + // Zero value. Never valid on the wire; its presence means a caller + // forgot to set the field. + EVENT_KIND_UNSPECIFIED = 0; + + // A completed model turn's canonical message (model.md §5). Usage + // and cost figures live inside this payload, not as separate fields or + // a separate EventKind (state-backend.md §5). + EVENT_KIND_MESSAGE = 1; + + // A tool invocation's call. + EVENT_KIND_TOOL_CALL = 2; + + // A tool invocation's result. + EVENT_KIND_TOOL_RESULT = 3; + + // A built Plan, prior to plan-ready dispatch. + EVENT_KIND_PLAN = 4; + + // The outcome of applying a Plan. + EVENT_KIND_APPLY = 5; + + // A context provider's Contribute output, or a memory provider's + // Recall output after kernel translation (memory.md §6). + EVENT_KIND_CONTEXT_CONTRIBUTION = 6; + + // A memory provider's write of a new record. + EVENT_KIND_MEMORY_WRITE = 7; + + // A memory provider's update of an existing record. + EVENT_KIND_MEMORY_UPDATE = 8; + + // A memory provider's deletion of an existing record. + EVENT_KIND_MEMORY_DELETE = 9; + + // Kernel-synthesized when a transform or veto hook subscriber fails + // (agent-loop/hook-dispatch.md#subscriber-error-handling) — never + // emitted by a plugin's own Emit call, only by the kernel itself + // dispatching a hook. Payload shape is + // pluggableharness.hook.v1.HookError, wrapped by the forthcoming + // event.v1 package's HookErrorEvent (state-backend.md §5). + EVENT_KIND_HOOK_ERROR = 10; +} diff --git a/api/pluggableharness/log/v1/log.proto b/api/pluggableharness/log/v1/types.proto similarity index 100% rename from api/pluggableharness/log/v1/log.proto rename to api/pluggableharness/log/v1/types.proto diff --git a/api/pluggableharness/memory/v1/errors.proto b/api/pluggableharness/memory/v1/errors.proto new file mode 100644 index 0000000..a6cb436 --- /dev/null +++ b/api/pluggableharness/memory/v1/errors.proto @@ -0,0 +1,49 @@ +syntax = "proto3"; + +package pluggableharness.memory.v1; + +option go_package = "github.com/pluggableharness/agent/pkg/memory/proto/v1;memoryv1"; + +// Structured error taxonomy for the memory provider plugin boundary. + +// MemoryErrorCategory is the structured error taxonomy every MemoryError +// classifies into. memory.md §11. +enum MemoryErrorCategory { + // Zero value. Never valid for a real error; its presence on the wire + // means a caller forgot to set the field. + MEMORY_ERROR_CATEGORY_UNSPECIFIED = 0; + // UpdateRecord/DeleteRecord/ApproveRecord/RejectRecord referenced an id + // that doesn't exist. + MEMORY_ERROR_CATEGORY_NOT_FOUND = 1; + // Record specified a MemoryType this provider doesn't support (absent + // from GetCapabilities.supported_types). + MEMORY_ERROR_CATEGORY_INVALID_TYPE = 2; + // ApproveRecord/RejectRecord was called against a provider with + // ratification_supported == false. + MEMORY_ERROR_CATEGORY_RATIFICATION_UNSUPPORTED = 3; + // Recall's candidate records exceed token_budget even after this + // provider's own truncation — the same MUST-self-truncate principle as + // context.md §6. + MEMORY_ERROR_CATEGORY_BUDGET_EXCEEDED = 4; + // This provider's backend storage was unreachable at call time. + MEMORY_ERROR_CATEGORY_SOURCE_UNAVAILABLE = 5; + // Any error not covered by a more specific category above. + MEMORY_ERROR_CATEGORY_UNKNOWN = 6; + // Record specified a MemoryScope this provider doesn't support (absent + // from GetCapabilities.supported_scopes) — the scope-taxonomy mirror of + // MEMORY_ERROR_CATEGORY_INVALID_TYPE above. + MEMORY_ERROR_CATEGORY_INVALID_SCOPE = 7; +} + +// MemoryError is the structured detail carried on a gRPC error status +// crossing the memory provider plugin boundary. memory.md §11. +message MemoryError { + // Which category of failure occurred. + MemoryErrorCategory category = 1; + + // Human-readable error detail. + string message = 2; + + // Whether the kernel MAY retry this call as-is. + bool retryable = 3; +} diff --git a/api/pluggableharness/memory/v1/memory.proto b/api/pluggableharness/memory/v1/memory.proto deleted file mode 100644 index 09f8f64..0000000 --- a/api/pluggableharness/memory/v1/memory.proto +++ /dev/null @@ -1,587 +0,0 @@ -syntax = "proto3"; - -// Package pluggableharness.memory.v1 defines the memory provider plugin protocol -// described in specifications/memory.md — plugins that persist knowledge -// across sessions (the write side) and recall it into future ones (the -// read side). A distinct plugin category with its own protocol, not a -// reuse of context.md's Contribute RPC, so record-specific data (type, -// scope, provenance, ratification status) stays first-class through a -// dedicated Recall RPC; the kernel adapts results into ContextSections -// before merging them into the assembled prompt (memory.md §6). -package pluggableharness.memory.v1; - -import "google/protobuf/struct.proto"; -import "google/protobuf/timestamp.proto"; -import "pluggableharness/common/v1/common.proto"; -import "pluggableharness/config/v1/config.proto"; -import "pluggableharness/content/v1/content.proto"; -import "pluggableharness/model/v1/model.proto"; -import "pluggableharness/render/v1/render.proto"; -import "pluggableharness/slashcommand/v1/slashcommand.proto"; - -option go_package = "github.com/pluggableharness/agent/pkg/memory/proto/v1;memoryv1"; - -// MemoryService implements the memory provider protocol described in -// specifications/memory.md §2-11. -service MemoryService { - // GetCapabilities reports what this provider supports before any other - // RPC is issued. Unary. memory.md §3. - rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); - - // Configure decodes this provider's agent.hcl config block, per the - // same schema-to-cty bridge contract as the rest of this spec series - // (model.md §3). Unary. memory.md §5. - rpc Configure(ConfigureRequest) returns (ConfigureResponse); - - // Recall is the read side: it fires at context-assemble time, competing - // for the same token budget pool as context providers, and returns the - // records this provider judges relevant. Unary. memory.md §6. - rpc Recall(RecallRequest) returns (RecallResponse); - - // Record is the write side: it creates a new record. Unary. memory.md §7. - rpc Record(RecordRequest) returns (RecordResponse); - - // UpdateRecord replaces an existing record's title/content wholesale - // (not a patch). MUST fail with a structured MemoryError (memory.md §11) - // if `id` doesn't match an existing record, rather than silently - // no-op'ing. Unary. memory.md §7. - rpc UpdateRecord(UpdateRecordRequest) returns (UpdateRecordResponse); - - // DeleteRecord removes an existing record. MUST fail with a structured - // MemoryError (memory.md §11) if `id` doesn't match an existing record, - // rather than silently no-op'ing. Unary. memory.md §7. - rpc DeleteRecord(DeleteRecordRequest) returns (DeleteRecordResponse); - - // ApproveRecord transitions a record from PENDING to CANONICAL. MAY be - // implemented; a provider declaring ratification_supported = true MUST - // implement it. Unary. memory.md §8. - rpc ApproveRecord(ApproveRecordRequest) returns (ApproveRecordResponse); - - // RejectRecord discards a pending draft entirely — not a soft delete. - // MAY be implemented, under the same ratification_supported = true - // requirement as ApproveRecord. Unary. memory.md §8. - rpc RejectRecord(RejectRecordRequest) returns (RejectRecordResponse); - - // Render returns this provider's own RenderTree for its content (e.g. a - // review-inbox view distinct from ordinary recall), in place of the - // kernel's generic fallback. MAY be implemented. Unary. memory.md §10. - rpc Render(RenderRequest) returns (RenderResponse); - - // ListRecords is the enumeration/audit path: paginated browsing of this - // provider's records, filterable by type/scope/status. Unlike Recall, - // PENDING records ARE listable here without any include_pending-style - // gate — this is the review-inbox path (e.g. a ratification review UI), - // not the budget-constrained per-turn recall path. MUST be implemented; - // cheap for any real backend. memory.md §7. - rpc ListRecords(ListRecordsRequest) returns (ListRecordsResponse); - - // GetRecord fetches exactly one record by id. MUST fail with a - // structured MemoryError (category NOT_FOUND) if `id` doesn't match an - // existing record, rather than returning an empty result. MUST be - // implemented; cheap for any real backend. memory.md §7. - rpc GetRecord(GetRecordRequest) returns (GetRecordResponse); - - // Describe reports this plugin build's own identity — {name, version, - // source, category, protocol_version} via ProducerRef — independent of - // any lock-file entry. This is the mechanism a dev_overrides-resolved - // binary (which has no provider {} lock entry to read identity from) - // uses to self-report at connection time; see - // docs/specifications/configuration/lock-file.md's dev_overrides note, - // which is the canonical explanation for this RPC across every plugin - // category that gains it in this protocol revision. - rpc Describe(DescribeRequest) returns (DescribeResponse); -} - -// GetCapabilitiesRequest carries no fields; GetCapabilities takes no -// parameters beyond the plugin handshake already having occurred. -message GetCapabilitiesRequest {} - -// MemoryType is the record taxonomy, fixed at the protocol level rather -// than provider-defined (memory.md §4). A record MUST declare exactly one -// MemoryType, and it is immutable after creation — recategorizing means -// DeleteRecord followed by a new Record call, not UpdateRecord. -enum MemoryType { - // Zero value. Never valid for a real record; its presence on the wire - // means a caller forgot to set the field. - MEMORY_TYPE_UNSPECIFIED = 0; - // The subject's role, goals, responsibilities, and knowledge. Tailors - // future behavior to who they are and what they already know. - MEMORY_TYPE_USER = 1; - // Guidance on how to approach work, captured from both corrections - // ("stop doing X") and confirmations ("yes, keep doing that") — both - // directions matter equally. - MEMORY_TYPE_FEEDBACK = 2; - // Ongoing work, goals, decisions, and incidents not otherwise derivable - // from code or git history. Decays faster than the other three types; a - // provider SHOULD weight recency more heavily for this type. - MEMORY_TYPE_PROJECT = 3; - // Pointers to where information lives in external systems (an issue - // tracker, a dashboard, a channel) — not the information itself. - MEMORY_TYPE_REFERENCE = 4; -} - -// MemoryScope is the visibility taxonomy a record declares, fixed at the -// protocol level (memory.md §4.1). Immutable per record once set, same as -// MemoryType. -enum MemoryScope { - // Zero value. Never valid for a real record; its presence on the wire - // means a caller forgot to set the field. - MEMORY_SCOPE_UNSPECIFIED = 0; - // Visible only within the session (and its descendants) that wrote it — - // not recalled by unrelated future sessions, though still durably - // logged to the state backend for audit. - MEMORY_SCOPE_SESSION = 1; - // Scoped to the current working directory/project, recalled by any - // session operating in that project. - MEMORY_SCOPE_PROJECT = 2; - // Recalled across every project, mirroring a memory system that spans - // all of a subject's work. - MEMORY_SCOPE_GLOBAL = 3; -} - -// RecordStatus distinguishes a fully-persisted record from one awaiting -// review under the optional ratification pattern (memory.md §8). -enum RecordStatus { - // Zero value. Never valid for a real record; its presence on the wire - // means a caller forgot to set the field. - RECORD_STATUS_UNSPECIFIED = 0; - // The record is part of what Recall normally surfaces. - RECORD_STATUS_CANONICAL = 1; - // The record is a drafted-but-not-yet-reviewed write. A provider with - // ratification_supported == false MUST NEVER return this status - // (memory.md §8). - RECORD_STATUS_PENDING = 2; -} - -// MemoryCapabilities is this provider's capability advertisement, returned -// by GetCapabilities. memory.md §3. -message MemoryCapabilities { - // The default token budget this provider requests for its Recall - // contributions, absent any override — same convention as context.md - // §6's reserved token_budget config field. MUST be set. - int64 default_token_budget = 1; - - // Which MemoryTypes this provider handles. MUST be set; MAY be a subset - // of the full MemoryType enum. - repeated MemoryType supported_types = 2; - - // Which MemoryScopes this provider handles. MUST be set; MAY be a - // subset of the full MemoryScope enum (e.g. project-only). - repeated MemoryScope supported_scopes = 3; - - // Whether this provider implements the ApproveRecord/RejectRecord - // ratification pattern (memory.md §8). MUST be set; defaults to false. - bool ratification_supported = 4; - - // Slash commands this provider contributes, per frontend.md §5. MAY be - // empty — the reference tools (memory.md §9.2) already cover the common - // remember/forget/search cases via the ordinary tool-provider path. - repeated pluggableharness.slashcommand.v1.SlashCommandSpec slash_commands = 5; - - // This provider's agent.hcl config schema, per configuration.md §4. - pluggableharness.config.v1.ConfigSchema config_schema = 6; - - // Which hook points (agent-loop/hook-dispatch.md) this provider declares - // HookSubscriberService.DispatchHook subscriptions for. Note: this - // category's implicit post_model_response (observe, every turn) and - // session_end (fires once) write triggers (memory.md §Write triggers) - // now ride the hook.v1 HookSubscriberService.DispatchHook surface — see - // protocol.md's Write triggers section for the payload shapes. The - // HookPoint enum itself lives in common.v1, not hook.v1 — hook.v1 - // imports this package's model/tool/plan dependencies, so a category - // capability message importing hook.v1 directly would cycle back - // through it; common.v1 is the shared leaf package instead. - repeated pluggableharness.common.v1.HookPoint supported_hook_points = 7; -} - -// GetCapabilitiesResponse wraps this provider's capability advertisement. -message GetCapabilitiesResponse { - // This provider's capabilities. - MemoryCapabilities capabilities = 1; -} - -// ConfigureRequest wraps this provider's already-decoded agent.hcl config -// block. memory.md §5. -message ConfigureRequest { - // The decoded config, per the schema this provider advertised in - // MemoryCapabilities.config_schema (configuration.md §4). Already - // decoded from HCL/cty by the kernel — this is a Struct because the - // shape is genuinely provider-defined at the proto level (see - // .claude/rules/proto.md's Struct carve-out), not because the value - // itself is untyped. - google.protobuf.Struct config = 1; -} - -// ConfigureResponse is empty on success. Errors surface as a gRPC status -// carrying a MemoryError in its structured detail, per grpc.md — not an -// in-band field on this message. -message ConfigureResponse {} - -// RecallRequest is the read-side query, issued at context-assemble time. -// memory.md §6. -message RecallRequest { - // The requesting session's id. - string session_id = 1; - - // The current turn within that session. A ULID, standardized across the - // whole protocol (matches plan.v1's turn_id field and context.v1's - // ContextRequest.turn_id, same treatment). Same field number as the - // retired int64 turn-number predecessor field; the rename is a - // sanctioned pre-release wire change, not a v2 bump. - string turn_id = 2; - - // The token budget this Recall call MUST self-truncate its returned - // records to. Competes for the same budget pool as context providers, - // resolved the same way context.md §6 resolves a context provider's cap. - int64 token_budget = 3; - - // The model this recall is being assembled for, mirroring context.md - // §4's ContextRequest field. Lets a provider pass a precise model - // reference into the CountTokens kernel callback (kernel-callbacks.md - // §2) — added per kernel-callbacks.md §6's correction. MUST be set. - pluggableharness.model.v1.ModelTarget model_target = 4; - - // Paths of files touched so far this turn, mirroring context.md §4's - // field of the same name. MAY be empty. - repeated string files_touched = 5; - - // The session's current working directory. - string working_directory = 6; - - // Restricts results to these MemoryTypes. MAY be empty, meaning all - // types this provider supports. - repeated MemoryType type_filter = 7; - - // Restricts results to these MemoryScopes. MAY be empty, meaning all - // scopes this provider supports. - repeated MemoryScope scope_filter = 8; - - // Whether PENDING-status records may be included in the response. MUST - // default to false — a PENDING record MUST NOT surface through ordinary - // recall unless this is true (memory.md §6, §8). - bool include_pending = 9; -} - -// RecallResponse carries the records this provider judges relevant to the -// requesting RecallRequest. -message RecallResponse { - // The recalled records, in this provider's own relevance order. - repeated MemoryRecord records = 1; -} - -// MemoryRecord is one persisted unit of memory. memory.md §6. -message MemoryRecord { - // A slug, unique within this provider. Kernel-enforced uniqueness per - // memory.md §7. - string id = 1; - - // This record's fixed taxonomy classification. Immutable after - // creation. - MemoryType type = 2; - - // This record's visibility scope. MUST be set; immutable after - // creation, like `type`. - MemoryScope scope = 3; - - // Human-readable title. - string title = 4; - - // The record's content. Text-only in v1, same constraint as context.md - // §4. - repeated pluggableharness.content.v1.ContentBlock content = 5; - - // This record's size, computed via the kernel's CountTokens callback - // (kernel-callbacks.md §2), never a provider-local heuristic. - int64 tokens = 6; - - // Whether this record is fully persisted or awaiting ratification. - RecordStatus status = 7; - - // Record ids this record references. MUST be set — kernel-parsed from - // "[[name]]" syntax in `content` at Record/UpdateRecord time - // (memory.md §7.1), not provider-populated. - repeated string links = 8; - - // When this record was first created. - google.protobuf.Timestamp created_at = 9; - - // When this record was last modified. - google.protobuf.Timestamp updated_at = 10; - - // Where this record came from and who wrote it. Kernel-populated at - // Record time, immutable thereafter — never provider-supplied or - // provider-mutable. memory.md's README already frames provenance as - // first-class; this is where the record shape finally carries it. - Provenance provenance = 11; - - // This record's recall-time relevance, in [0, 1]. Set only on - // Recall/ListRecords responses — never persisted, never present on a - // Record/UpdateRecord request or response. Lets the kernel merge - // multiple memory providers' results under one shared budget - // (data-types.md#recallrequest--memoryrecord) using a comparable score - // rather than provider-opaque ordering alone. A provider setting this - // MUST normalize it to [0, 1]; scores from different providers are only - // meaningfully comparable if each normalizes to the same range. - optional double relevance_score = 12; -} - -// Provenance records where a MemoryRecord came from and who wrote it. -// Kernel-populated at Record time; immutable afterward, like the rest of a -// record's write-time metadata. -message Provenance { - // The session id that produced this record. - string source_session_id = 1; - - // The turn id (ULID, see ContextRequest.turn_id) within source_session_id - // that produced this record, when known. Absent for a record written - // outside normal turn flow (e.g. a backfill/import). - optional string source_turn_id = 2; - - // The producing plugin's declared name, or the reference tool path that - // wrote it (e.g. "memory.remember"). Kernel-populated from the calling - // context, not provider-supplied. - string recorded_by = 3; -} - -// RecordRequest creates a new record. memory.md §7. -message RecordRequest { - // This record's fixed taxonomy classification. MUST be set. - MemoryType type = 1; - - // This record's visibility scope. MUST be set. - MemoryScope scope = 2; - - // An author-suggested slug. MAY be omitted, in which case the provider - // derives one from content; the kernel disambiguates collisions with a - // numeric suffix (memory.md §7) rather than overwriting or rejecting. - optional string id = 3; - - // Human-readable title. - string title = 4; - - // The record's content. - repeated pluggableharness.content.v1.ContentBlock content = 5; -} - -// RecordResult is the shared outcome shape for Record (via -// RecordResponse), UpdateRecord (via UpdateRecordResponse), and -// ApproveRecord (via ApproveRecordResponse) — a reusable domain type, not -// itself an RPC response type for more than one RPC. -message RecordResult { - // The final assigned slug. MUST be set. - string id = 1; - - // Whether the write is fully persisted or awaiting ratification. - RecordStatus status = 2; -} - -// RecordResponse wraps Record's outcome. -message RecordResponse { - // The newly created record's outcome. - RecordResult result = 1; -} - -// UpdateRecordRequest replaces an existing record's title/content -// wholesale. memory.md §7. -message UpdateRecordRequest { - // The existing record's id. MUST match an existing record, or the call - // fails with a structured MemoryError. - string id = 1; - - // The record's new title. Unset leaves the existing title unchanged. - optional string title = 2; - - // The record's new content, replacing the existing content wholesale — - // not a patch. MUST be set. - repeated pluggableharness.content.v1.ContentBlock content = 3; -} - -// UpdateRecordResponse wraps UpdateRecord's outcome, which is the same -// shape as Record's (memory.md §7) but kept as its own per-RPC response -// type — RecordResult itself stays a reusable domain type, not an RPC -// response type shared across RPCs. -message UpdateRecordResponse { - // The updated record's outcome. - RecordResult result = 1; -} - -// DeleteRecordRequest identifies the record to remove. memory.md §7. -message DeleteRecordRequest { - // The existing record's id. MUST match an existing record, or the call - // fails with a structured MemoryError. - string id = 1; -} - -// DeleteResult is the shared outcome shape for DeleteRecord (via -// DeleteRecordResponse) and RejectRecord (via RejectRecordResponse) — a -// reusable domain type, not itself an RPC response type for more than one -// RPC. -message DeleteResult { - // True if a record was actually removed. - bool deleted = 1; -} - -// DeleteRecordResponse wraps DeleteRecord's outcome. -message DeleteRecordResponse { - // Whether the record was actually removed. - DeleteResult result = 1; -} - -// ApproveRecordRequest identifies the pending record to transition to -// CANONICAL. memory.md §8. -message ApproveRecordRequest { - // The pending record's id. - string id = 1; -} - -// ApproveRecordResponse wraps ApproveRecord's outcome, which is the same -// shape as Record's (memory.md §8) but kept as its own per-RPC response -// type — RecordResult itself stays a reusable domain type, not an RPC -// response type shared across RPCs. -message ApproveRecordResponse { - // The now-canonical record's outcome. - RecordResult result = 1; -} - -// RejectRecordRequest identifies the pending record to discard entirely. -// memory.md §8. -message RejectRecordRequest { - // The pending record's id. - string id = 1; -} - -// RejectRecordResponse wraps RejectRecord's outcome, which is the same -// shape as DeleteRecord's (memory.md §8) but kept as its own per-RPC -// response type — DeleteResult itself stays a reusable domain type, not an -// RPC response type shared across RPCs. -message RejectRecordResponse { - // Whether the pending draft was actually discarded. - DeleteResult result = 1; -} - -// MemoryErrorCategory is the structured error taxonomy every MemoryError -// classifies into. memory.md §11. -enum MemoryErrorCategory { - // Zero value. Never valid for a real error; its presence on the wire - // means a caller forgot to set the field. - MEMORY_ERROR_CATEGORY_UNSPECIFIED = 0; - // UpdateRecord/DeleteRecord/ApproveRecord/RejectRecord referenced an id - // that doesn't exist. - MEMORY_ERROR_CATEGORY_NOT_FOUND = 1; - // Record specified a MemoryType this provider doesn't support (absent - // from GetCapabilities.supported_types). - MEMORY_ERROR_CATEGORY_INVALID_TYPE = 2; - // ApproveRecord/RejectRecord was called against a provider with - // ratification_supported == false. - MEMORY_ERROR_CATEGORY_RATIFICATION_UNSUPPORTED = 3; - // Recall's candidate records exceed token_budget even after this - // provider's own truncation — the same MUST-self-truncate principle as - // context.md §6. - MEMORY_ERROR_CATEGORY_BUDGET_EXCEEDED = 4; - // This provider's backend storage was unreachable at call time. - MEMORY_ERROR_CATEGORY_SOURCE_UNAVAILABLE = 5; - // Any error not covered by a more specific category above. - MEMORY_ERROR_CATEGORY_UNKNOWN = 6; - // Record specified a MemoryScope this provider doesn't support (absent - // from GetCapabilities.supported_scopes) — the scope-taxonomy mirror of - // MEMORY_ERROR_CATEGORY_INVALID_TYPE above. - MEMORY_ERROR_CATEGORY_INVALID_SCOPE = 7; -} - -// MemoryError is the structured detail carried on a gRPC error status -// crossing the memory provider plugin boundary. memory.md §11. -message MemoryError { - // Which category of failure occurred. - MemoryErrorCategory category = 1; - - // Human-readable error detail. - string message = 2; - - // Whether the kernel MAY retry this call as-is. - bool retryable = 3; -} - -// RenderRequest carries the opaque payload to render. memory.md §10. -message RenderRequest { - // The opaque emitted payload to render — see grpc.md's Emit->Render-> - // Paint carve-out; this is the one field in this file that is - // deliberately untyped bytes rather than a concrete message. - bytes payload = 1; - - // The schema version this payload was emitted against, so a Render - // implementation can detect drift between the version it was built - // against and the version live in a running session. See - // ../frontend/render-tree.md#schema-versioning for the canonical - // definition of this field's semantics (owned by the frontend/widget - // workstream; this field just carries the same value). - string schema_version = 2; -} - -// RenderResponse wraps this provider's rendered output. memory.md §10. -message RenderResponse { - // The rendered tree, per frontend.md §1. - pluggableharness.render.v1.RenderTree tree = 1; -} - -// ListRecordsRequest is the enumeration/audit query: paginated browsing of -// this provider's records, filterable by type/scope/status. Unlike -// RecallRequest, there is no include_pending gate — this is the -// review-inbox path (e.g. a ratification review UI or generic record -// browsing), where PENDING records ARE listable, not the budget-constrained -// per-turn recall path. memory.md §7. -message ListRecordsRequest { - // Restricts results to these MemoryTypes. MAY be empty, meaning all - // types this provider supports. - repeated MemoryType type_filter = 1; - - // Restricts results to these MemoryScopes. MAY be empty, meaning all - // scopes this provider supports. - repeated MemoryScope scope_filter = 2; - - // Restricts results to this RecordStatus. Unset means both CANONICAL and - // PENDING records are eligible — this is the one path where a PENDING - // record is listable without any include_pending-style gate. - optional RecordStatus status_filter = 3; - - // Maximum number of records to return in one page. - int32 page_size = 4; - - // Opaque continuation token from a prior ListRecordsResponse. - // next_page_token. Empty on the first page. - string page_token = 5; -} - -// ListRecordsResponse carries one page of matching records. -message ListRecordsResponse { - // This page's records. - repeated MemoryRecord records = 1; - - // Opaque continuation token for the next page. Empty when this is the - // last page. - string next_page_token = 2; -} - -// GetRecordRequest identifies exactly one record to fetch by id. -message GetRecordRequest { - // The record's id. MUST match an existing record, or the call fails with - // a structured MemoryError (category NOT_FOUND). - string id = 1; -} - -// GetRecordResponse wraps the fetched record. -message GetRecordResponse { - // The fetched record. - MemoryRecord record = 1; -} - -// DescribeRequest carries no fields — Describe takes no request-scoped -// parameters. -message DescribeRequest {} - -// DescribeResponse reports this plugin build's own identity. See the -// Describe RPC comment on MemoryService above. -message DescribeResponse { - // This plugin build's identity: name, version, source, category, - // protocol_version. - pluggableharness.common.v1.ProducerRef producer = 1; -} diff --git a/api/pluggableharness/memory/v1/rpc_request.proto b/api/pluggableharness/memory/v1/rpc_request.proto new file mode 100644 index 0000000..ab11fc4 --- /dev/null +++ b/api/pluggableharness/memory/v1/rpc_request.proto @@ -0,0 +1,184 @@ +syntax = "proto3"; + +package pluggableharness.memory.v1; + +import "google/protobuf/struct.proto"; +import "pluggableharness/content/v1/types.proto"; +import "pluggableharness/memory/v1/types.proto"; +import "pluggableharness/model/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/memory/proto/v1;memoryv1"; + +// Request messages for MemoryService's unary RPCs. + +// GetCapabilitiesRequest carries no fields; GetCapabilities takes no +// parameters beyond the plugin handshake already having occurred. +message GetCapabilitiesRequest {} + +// ConfigureRequest wraps this provider's already-decoded agent.hcl config +// block. memory.md §5. +message ConfigureRequest { + // The decoded config, per the schema this provider advertised in + // MemoryCapabilities.config_schema (configuration.md §4). Already + // decoded from HCL/cty by the kernel — this is a Struct because the + // shape is genuinely provider-defined at the proto level (see + // .claude/rules/proto.md's Struct carve-out), not because the value + // itself is untyped. + google.protobuf.Struct config = 1; +} + +// RecallRequest is the read-side query, issued at context-assemble time. +// memory.md §6. +message RecallRequest { + // The requesting session's id. + string session_id = 1; + + // The current turn within that session. A ULID, standardized across the + // whole protocol (matches plan.v1's turn_id field and context.v1's + // ContextRequest.turn_id, same treatment). Same field number as the + // retired int64 turn-number predecessor field; the rename is a + // sanctioned pre-release wire change, not a v2 bump. + string turn_id = 2; + + // The token budget this Recall call MUST self-truncate its returned + // records to. Competes for the same budget pool as context providers, + // resolved the same way context.md §6 resolves a context provider's cap. + int64 token_budget = 3; + + // The model this recall is being assembled for, mirroring context.md + // §4's ContextRequest field. Lets a provider pass a precise model + // reference into the CountTokens kernel callback (kernel-callbacks.md + // §2) — added per kernel-callbacks.md §6's correction. MUST be set. + pluggableharness.model.v1.ModelTarget model_target = 4; + + // Paths of files touched so far this turn, mirroring context.md §4's + // field of the same name. MAY be empty. + repeated string files_touched = 5; + + // The session's current working directory. + string working_directory = 6; + + // Restricts results to these MemoryTypes. MAY be empty, meaning all + // types this provider supports. + repeated MemoryType type_filter = 7; + + // Restricts results to these MemoryScopes. MAY be empty, meaning all + // scopes this provider supports. + repeated MemoryScope scope_filter = 8; + + // Whether PENDING-status records may be included in the response. MUST + // default to false — a PENDING record MUST NOT surface through ordinary + // recall unless this is true (memory.md §6, §8). + bool include_pending = 9; +} + +// RecordRequest creates a new record. memory.md §7. +message RecordRequest { + // This record's fixed taxonomy classification. MUST be set. + MemoryType type = 1; + + // This record's visibility scope. MUST be set. + MemoryScope scope = 2; + + // An author-suggested slug. MAY be omitted, in which case the provider + // derives one from content; the kernel disambiguates collisions with a + // numeric suffix (memory.md §7) rather than overwriting or rejecting. + optional string id = 3; + + // Human-readable title. + string title = 4; + + // The record's content. + repeated pluggableharness.content.v1.ContentBlock content = 5; +} + +// UpdateRecordRequest replaces an existing record's title/content +// wholesale. memory.md §7. +message UpdateRecordRequest { + // The existing record's id. MUST match an existing record, or the call + // fails with a structured MemoryError. + string id = 1; + + // The record's new title. Unset leaves the existing title unchanged. + optional string title = 2; + + // The record's new content, replacing the existing content wholesale — + // not a patch. MUST be set. + repeated pluggableharness.content.v1.ContentBlock content = 3; +} + +// DeleteRecordRequest identifies the record to remove. memory.md §7. +message DeleteRecordRequest { + // The existing record's id. MUST match an existing record, or the call + // fails with a structured MemoryError. + string id = 1; +} + +// ApproveRecordRequest identifies the pending record to transition to +// CANONICAL. memory.md §8. +message ApproveRecordRequest { + // The pending record's id. + string id = 1; +} + +// RejectRecordRequest identifies the pending record to discard entirely. +// memory.md §8. +message RejectRecordRequest { + // The pending record's id. + string id = 1; +} + +// RenderRequest carries the opaque payload to render. memory.md §10. +message RenderRequest { + // The opaque emitted payload to render — see grpc.md's Emit->Render-> + // Paint carve-out; this is the one field in this file that is + // deliberately untyped bytes rather than a concrete message. + bytes payload = 1; + + // The schema version this payload was emitted against, so a Render + // implementation can detect drift between the version it was built + // against and the version live in a running session. See + // ../frontend/render-tree.md#schema-versioning for the canonical + // definition of this field's semantics (owned by the frontend/widget + // workstream; this field just carries the same value). + string schema_version = 2; +} + +// ListRecordsRequest is the enumeration/audit query: paginated browsing of +// this provider's records, filterable by type/scope/status. Unlike +// RecallRequest, there is no include_pending gate — this is the +// review-inbox path (e.g. a ratification review UI or generic record +// browsing), where PENDING records ARE listable, not the budget-constrained +// per-turn recall path. memory.md §7. +message ListRecordsRequest { + // Restricts results to these MemoryTypes. MAY be empty, meaning all + // types this provider supports. + repeated MemoryType type_filter = 1; + + // Restricts results to these MemoryScopes. MAY be empty, meaning all + // scopes this provider supports. + repeated MemoryScope scope_filter = 2; + + // Restricts results to this RecordStatus. Unset means both CANONICAL and + // PENDING records are eligible — this is the one path where a PENDING + // record is listable without any include_pending-style gate. + optional RecordStatus status_filter = 3; + + // Maximum number of records to return in one page. + int32 page_size = 4; + + // Opaque continuation token from a prior ListRecordsResponse. + // next_page_token. Empty on the first page. + string page_token = 5; +} + +// GetRecordRequest identifies exactly one record to fetch by id. +message GetRecordRequest { + // The record's id. MUST match an existing record, or the call fails with + // a structured MemoryError (category NOT_FOUND). + string id = 1; +} + +// DescribeRequest carries no fields — Describe takes no request-scoped +// parameters. +message DescribeRequest {} diff --git a/api/pluggableharness/memory/v1/rpc_response.proto b/api/pluggableharness/memory/v1/rpc_response.proto new file mode 100644 index 0000000..7023b4b --- /dev/null +++ b/api/pluggableharness/memory/v1/rpc_response.proto @@ -0,0 +1,98 @@ +syntax = "proto3"; + +package pluggableharness.memory.v1; + +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/memory/v1/types.proto"; +import "pluggableharness/render/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/memory/proto/v1;memoryv1"; + +// Response messages for MemoryService's unary RPCs. + +// GetCapabilitiesResponse wraps this provider's capability advertisement. +message GetCapabilitiesResponse { + // This provider's capabilities. + MemoryCapabilities capabilities = 1; +} + +// ConfigureResponse is empty on success. Errors surface as a gRPC status +// carrying a MemoryError in its structured detail, per grpc.md — not an +// in-band field on this message. +message ConfigureResponse {} + +// RecallResponse carries the records this provider judges relevant to the +// requesting RecallRequest. +message RecallResponse { + // The recalled records, in this provider's own relevance order. + repeated MemoryRecord records = 1; +} + +// RecordResponse wraps Record's outcome. +message RecordResponse { + // The newly created record's outcome. + RecordResult result = 1; +} + +// UpdateRecordResponse wraps UpdateRecord's outcome, which is the same +// shape as Record's (memory.md §7) but kept as its own per-RPC response +// type — RecordResult itself stays a reusable domain type, not an RPC +// response type shared across RPCs. +message UpdateRecordResponse { + // The updated record's outcome. + RecordResult result = 1; +} + +// DeleteRecordResponse wraps DeleteRecord's outcome. +message DeleteRecordResponse { + // Whether the record was actually removed. + DeleteResult result = 1; +} + +// ApproveRecordResponse wraps ApproveRecord's outcome, which is the same +// shape as Record's (memory.md §8) but kept as its own per-RPC response +// type — RecordResult itself stays a reusable domain type, not an RPC +// response type shared across RPCs. +message ApproveRecordResponse { + // The now-canonical record's outcome. + RecordResult result = 1; +} + +// RejectRecordResponse wraps RejectRecord's outcome, which is the same +// shape as DeleteRecord's (memory.md §8) but kept as its own per-RPC +// response type — DeleteResult itself stays a reusable domain type, not an +// RPC response type shared across RPCs. +message RejectRecordResponse { + // Whether the pending draft was actually discarded. + DeleteResult result = 1; +} + +// RenderResponse wraps this provider's rendered output. memory.md §10. +message RenderResponse { + // The rendered tree, per frontend.md §1. + pluggableharness.render.v1.RenderTree tree = 1; +} + +// ListRecordsResponse carries one page of matching records. +message ListRecordsResponse { + // This page's records. + repeated MemoryRecord records = 1; + + // Opaque continuation token for the next page. Empty when this is the + // last page. + string next_page_token = 2; +} + +// GetRecordResponse wraps the fetched record. +message GetRecordResponse { + // The fetched record. + MemoryRecord record = 1; +} + +// DescribeResponse reports this plugin build's own identity. See the +// Describe RPC comment on MemoryService above. +message DescribeResponse { + // This plugin build's identity: name, version, source, category, + // protocol_version. + pluggableharness.common.v1.ProducerRef producer = 1; +} diff --git a/api/pluggableharness/memory/v1/service.proto b/api/pluggableharness/memory/v1/service.proto new file mode 100644 index 0000000..dd65055 --- /dev/null +++ b/api/pluggableharness/memory/v1/service.proto @@ -0,0 +1,87 @@ +syntax = "proto3"; + +// Package pluggableharness.memory.v1 defines the memory provider plugin protocol +// described in specifications/memory.md — plugins that persist knowledge +// across sessions (the write side) and recall it into future ones (the +// read side). A distinct plugin category with its own protocol, not a +// reuse of context.md's Contribute RPC, so record-specific data (type, +// scope, provenance, ratification status) stays first-class through a +// dedicated Recall RPC; the kernel adapts results into ContextSections +// before merging them into the assembled prompt (memory.md §6). +package pluggableharness.memory.v1; + +import "pluggableharness/memory/v1/rpc_request.proto"; +import "pluggableharness/memory/v1/rpc_response.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/memory/proto/v1;memoryv1"; + +// MemoryService implements the memory provider protocol described in +// specifications/memory.md §2-11. +service MemoryService { + // GetCapabilities reports what this provider supports before any other + // RPC is issued. Unary. memory.md §3. + rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); + + // Configure decodes this provider's agent.hcl config block, per the + // same schema-to-cty bridge contract as the rest of this spec series + // (model.md §3). Unary. memory.md §5. + rpc Configure(ConfigureRequest) returns (ConfigureResponse); + + // Recall is the read side: it fires at context-assemble time, competing + // for the same token budget pool as context providers, and returns the + // records this provider judges relevant. Unary. memory.md §6. + rpc Recall(RecallRequest) returns (RecallResponse); + + // Record is the write side: it creates a new record. Unary. memory.md §7. + rpc Record(RecordRequest) returns (RecordResponse); + + // UpdateRecord replaces an existing record's title/content wholesale + // (not a patch). MUST fail with a structured MemoryError (memory.md §11) + // if `id` doesn't match an existing record, rather than silently + // no-op'ing. Unary. memory.md §7. + rpc UpdateRecord(UpdateRecordRequest) returns (UpdateRecordResponse); + + // DeleteRecord removes an existing record. MUST fail with a structured + // MemoryError (memory.md §11) if `id` doesn't match an existing record, + // rather than silently no-op'ing. Unary. memory.md §7. + rpc DeleteRecord(DeleteRecordRequest) returns (DeleteRecordResponse); + + // ApproveRecord transitions a record from PENDING to CANONICAL. MAY be + // implemented; a provider declaring ratification_supported = true MUST + // implement it. Unary. memory.md §8. + rpc ApproveRecord(ApproveRecordRequest) returns (ApproveRecordResponse); + + // RejectRecord discards a pending draft entirely — not a soft delete. + // MAY be implemented, under the same ratification_supported = true + // requirement as ApproveRecord. Unary. memory.md §8. + rpc RejectRecord(RejectRecordRequest) returns (RejectRecordResponse); + + // Render returns this provider's own RenderTree for its content (e.g. a + // review-inbox view distinct from ordinary recall), in place of the + // kernel's generic fallback. MAY be implemented. Unary. memory.md §10. + rpc Render(RenderRequest) returns (RenderResponse); + + // ListRecords is the enumeration/audit path: paginated browsing of this + // provider's records, filterable by type/scope/status. Unlike Recall, + // PENDING records ARE listable here without any include_pending-style + // gate — this is the review-inbox path (e.g. a ratification review UI), + // not the budget-constrained per-turn recall path. MUST be implemented; + // cheap for any real backend. memory.md §7. + rpc ListRecords(ListRecordsRequest) returns (ListRecordsResponse); + + // GetRecord fetches exactly one record by id. MUST fail with a + // structured MemoryError (category NOT_FOUND) if `id` doesn't match an + // existing record, rather than returning an empty result. MUST be + // implemented; cheap for any real backend. memory.md §7. + rpc GetRecord(GetRecordRequest) returns (GetRecordResponse); + + // Describe reports this plugin build's own identity — {name, version, + // source, category, protocol_version} via ProducerRef — independent of + // any lock-file entry. This is the mechanism a dev_overrides-resolved + // binary (which has no provider {} lock entry to read identity from) + // uses to self-report at connection time; see + // docs/specifications/configuration/lock-file.md's dev_overrides note, + // which is the canonical explanation for this RPC across every plugin + // category that gains it in this protocol revision. + rpc Describe(DescribeRequest) returns (DescribeResponse); +} diff --git a/api/pluggableharness/memory/v1/types.proto b/api/pluggableharness/memory/v1/types.proto new file mode 100644 index 0000000..3677af7 --- /dev/null +++ b/api/pluggableharness/memory/v1/types.proto @@ -0,0 +1,208 @@ +syntax = "proto3"; + +package pluggableharness.memory.v1; + +import "google/protobuf/timestamp.proto"; +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/config/v1/types.proto"; +import "pluggableharness/content/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/memory/proto/v1;memoryv1"; + +// Domain types shared across MemoryService's requests and responses. + +// MemoryType is the record taxonomy, fixed at the protocol level rather +// than provider-defined (memory.md §4). A record MUST declare exactly one +// MemoryType, and it is immutable after creation — recategorizing means +// DeleteRecord followed by a new Record call, not UpdateRecord. +enum MemoryType { + // Zero value. Never valid for a real record; its presence on the wire + // means a caller forgot to set the field. + MEMORY_TYPE_UNSPECIFIED = 0; + // The subject's role, goals, responsibilities, and knowledge. Tailors + // future behavior to who they are and what they already know. + MEMORY_TYPE_USER = 1; + // Guidance on how to approach work, captured from both corrections + // ("stop doing X") and confirmations ("yes, keep doing that") — both + // directions matter equally. + MEMORY_TYPE_FEEDBACK = 2; + // Ongoing work, goals, decisions, and incidents not otherwise derivable + // from code or git history. Decays faster than the other three types; a + // provider SHOULD weight recency more heavily for this type. + MEMORY_TYPE_PROJECT = 3; + // Pointers to where information lives in external systems (an issue + // tracker, a dashboard, a channel) — not the information itself. + MEMORY_TYPE_REFERENCE = 4; +} + +// MemoryScope is the visibility taxonomy a record declares, fixed at the +// protocol level (memory.md §4.1). Immutable per record once set, same as +// MemoryType. +enum MemoryScope { + // Zero value. Never valid for a real record; its presence on the wire + // means a caller forgot to set the field. + MEMORY_SCOPE_UNSPECIFIED = 0; + // Visible only within the session (and its descendants) that wrote it — + // not recalled by unrelated future sessions, though still durably + // logged to the state backend for audit. + MEMORY_SCOPE_SESSION = 1; + // Scoped to the current working directory/project, recalled by any + // session operating in that project. + MEMORY_SCOPE_PROJECT = 2; + // Recalled across every project, mirroring a memory system that spans + // all of a subject's work. + MEMORY_SCOPE_GLOBAL = 3; +} + +// RecordStatus distinguishes a fully-persisted record from one awaiting +// review under the optional ratification pattern (memory.md §8). +enum RecordStatus { + // Zero value. Never valid for a real record; its presence on the wire + // means a caller forgot to set the field. + RECORD_STATUS_UNSPECIFIED = 0; + // The record is part of what Recall normally surfaces. + RECORD_STATUS_CANONICAL = 1; + // The record is a drafted-but-not-yet-reviewed write. A provider with + // ratification_supported == false MUST NEVER return this status + // (memory.md §8). + RECORD_STATUS_PENDING = 2; +} + +// MemoryCapabilities is this provider's capability advertisement, returned +// by GetCapabilities. memory.md §3. +message MemoryCapabilities { + // The default token budget this provider requests for its Recall + // contributions, absent any override — same convention as context.md + // §6's reserved token_budget config field. MUST be set. + int64 default_token_budget = 1; + + // Which MemoryTypes this provider handles. MUST be set; MAY be a subset + // of the full MemoryType enum. + repeated MemoryType supported_types = 2; + + // Which MemoryScopes this provider handles. MUST be set; MAY be a + // subset of the full MemoryScope enum (e.g. project-only). + repeated MemoryScope supported_scopes = 3; + + // Whether this provider implements the ApproveRecord/RejectRecord + // ratification pattern (memory.md §8). MUST be set; defaults to false. + bool ratification_supported = 4; + + // Prompt-expansion slash commands this provider contributes, per + // frontend.md §5. MAY be empty — the reference tools (memory.md §9.2) + // already cover the common remember/forget/search cases via the + // ordinary tool-provider path. A direct-invoke command is declared by + // a slashcommand.v1 provider instead (specifications/slashcommand/), + // never here. + repeated pluggableharness.common.v1.PromptExpansionSpec slash_commands = 5; + + // This provider's agent.hcl config schema, per configuration.md §4. + pluggableharness.config.v1.ConfigSchema config_schema = 6; + + // Which hook points (agent-loop/hook-dispatch.md) this provider declares + // HookSubscriberService.DispatchHook subscriptions for. Note: this + // category's implicit post_model_response (observe, every turn) and + // session_end (fires once) write triggers (memory.md §Write triggers) + // now ride the hook.v1 HookSubscriberService.DispatchHook surface — see + // protocol.md's Write triggers section for the payload shapes. The + // HookPoint enum itself lives in common.v1, not hook.v1 — hook.v1 + // imports this package's model/tool/plan dependencies, so a category + // capability message importing hook.v1 directly would cycle back + // through it; common.v1 is the shared leaf package instead. + repeated pluggableharness.common.v1.HookPoint supported_hook_points = 7; +} + +// MemoryRecord is one persisted unit of memory. memory.md §6. +message MemoryRecord { + // A slug, unique within this provider. Kernel-enforced uniqueness per + // memory.md §7. + string id = 1; + + // This record's fixed taxonomy classification. Immutable after + // creation. + MemoryType type = 2; + + // This record's visibility scope. MUST be set; immutable after + // creation, like `type`. + MemoryScope scope = 3; + + // Human-readable title. + string title = 4; + + // The record's content. Text-only in v1, same constraint as context.md + // §4. + repeated pluggableharness.content.v1.ContentBlock content = 5; + + // This record's size, computed via the kernel's CountTokens callback + // (kernel-callbacks.md §2), never a provider-local heuristic. + int64 tokens = 6; + + // Whether this record is fully persisted or awaiting ratification. + RecordStatus status = 7; + + // Record ids this record references. MUST be set — kernel-parsed from + // "[[name]]" syntax in `content` at Record/UpdateRecord time + // (memory.md §7.1), not provider-populated. + repeated string links = 8; + + // When this record was first created. + google.protobuf.Timestamp created_at = 9; + + // When this record was last modified. + google.protobuf.Timestamp updated_at = 10; + + // Where this record came from and who wrote it. Kernel-populated at + // Record time, immutable thereafter — never provider-supplied or + // provider-mutable. memory.md's README already frames provenance as + // first-class; this is where the record shape finally carries it. + Provenance provenance = 11; + + // This record's recall-time relevance, in [0, 1]. Set only on + // Recall/ListRecords responses — never persisted, never present on a + // Record/UpdateRecord request or response. Lets the kernel merge + // multiple memory providers' results under one shared budget + // (data-types.md#recallrequest--memoryrecord) using a comparable score + // rather than provider-opaque ordering alone. A provider setting this + // MUST normalize it to [0, 1]; scores from different providers are only + // meaningfully comparable if each normalizes to the same range. + optional double relevance_score = 12; +} + +// Provenance records where a MemoryRecord came from and who wrote it. +// Kernel-populated at Record time; immutable afterward, like the rest of a +// record's write-time metadata. +message Provenance { + // The session id that produced this record. + string source_session_id = 1; + + // The turn id (ULID, see ContextRequest.turn_id) within source_session_id + // that produced this record, when known. Absent for a record written + // outside normal turn flow (e.g. a backfill/import). + optional string source_turn_id = 2; + + // The producing plugin's declared name, or the reference tool path that + // wrote it (e.g. "memory.remember"). Kernel-populated from the calling + // context, not provider-supplied. + string recorded_by = 3; +} + +// RecordResult is the shared outcome shape for Record (via +// RecordResponse), UpdateRecord (via UpdateRecordResponse), and +// ApproveRecord (via ApproveRecordResponse) — a reusable domain type, not +// itself an RPC response type for more than one RPC. +message RecordResult { + // The final assigned slug. MUST be set. + string id = 1; + + // Whether the write is fully persisted or awaiting ratification. + RecordStatus status = 2; +} + +// DeleteResult is the shared outcome shape for DeleteRecord (via +// DeleteRecordResponse) and RejectRecord (via RejectRecordResponse) — a +// reusable domain type, not itself an RPC response type for more than one +// RPC. +message DeleteResult { + // True if a record was actually removed. + bool deleted = 1; +} diff --git a/api/pluggableharness/metric/v1/types.proto b/api/pluggableharness/metric/v1/types.proto new file mode 100644 index 0000000..01bae7f --- /dev/null +++ b/api/pluggableharness/metric/v1/types.proto @@ -0,0 +1,82 @@ +syntax = "proto3"; + +// Package pluggableharness.metric.v1 defines the wire shape of one relayed +// metric observation, described in specifications/observability.md and +// consumed by pluggableharness.kernel.v1's RecordMetrics RPC +// (KernelCallbackService). Unlike pluggableharness.trace.v1's Span, a +// MetricRecord is NOT relayed transparently: the kernel records each +// observation against its own, kernel-owned instrument rather than +// forwarding it as OTLP, and bounds `attributes`' key set before the +// observation reaches any exporter +// (observability.md#the-tracing-metrics-asymmetry) — the non-negotiable +// metric-cardinality rule in .claude/rules/logging-telemetry.md would +// otherwise let an arbitrary third-party plugin hand the kernel an +// unbounded attribute set. +package pluggableharness.metric.v1; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/metric/proto/v1;metricv1"; + +// MetricKind identifies which of OTel's three instrument shapes a +// MetricRecord observation belongs to. +enum MetricKind { + // Zero value. Never valid on the wire; its presence means a caller + // forgot to set the field. + METRIC_KIND_UNSPECIFIED = 0; + // A monotonically increasing sum, e.g. a request count. + METRIC_KIND_COUNTER = 1; + // A sum that can both increase and decrease, e.g. an active-connection + // gauge. + METRIC_KIND_UP_DOWN_COUNTER = 2; + // One observation to be aggregated into a distribution, e.g. a call + // duration. A MetricRecord carries exactly one observation, never a + // pre-aggregated bucket set — the kernel's own histogram instrument + // performs the aggregation, the same way an OTel Histogram instrument's + // Record(ctx, value) call does on the reporting side. + METRIC_KIND_HISTOGRAM = 3; +} + +// MetricRecord is one metric observation, relayed by RecordMetrics. +message MetricRecord { + // The metric's name. MUST be set. The kernel records this observation + // against an instrument named "plugin.{category}.{name}.{metric name}" + // — {category}/{name} come from the calling plugin's server-derived + // producer identity, never from a field on this message + // (kernel-callbacks.md's anti-spoof rule, applied here identically to + // Emit/Log/Publish). + string name = 1; + + // A human-readable description of what this metric measures. MAY be + // empty. + string description = 2; + + // The metric's unit, UCUM-style (e.g. "ms", "By", "1"). MAY be empty. + string unit = 3; + + // Which instrument shape this observation belongs to. MUST be set. The + // kernel MUST reject a RecordMetrics call whose kind disagrees with a + // previously-created instrument of the same name. + MetricKind kind = 4; + + // The observed value. Exactly one variant MUST be set. + oneof value { + // An integer observation. + int64 int_value = 5; + // A floating-point observation. + double double_value = 6; + } + + // Open-ended key/value attributes for this observation (e.g. a status + // label). A genuine open-ended-by-design case, not a structured-payload + // dodge (.claude/rules/proto.md's map carve-out) — but + // unlike trace.v1.Span's Struct-typed attributes, the kernel bounds this + // map's key set per instrument before the observation reaches any + // exporter (observability.md#the-tracing-metrics-asymmetry); a key + // beyond the bound is dropped, not rejected, with a throttled WARN log + // identifying what was dropped. + map attributes = 7; + + // When this observation occurred at the plugin. MUST be set. + google.protobuf.Timestamp time = 8; +} diff --git a/api/pluggableharness/model/v1/errors.proto b/api/pluggableharness/model/v1/errors.proto new file mode 100644 index 0000000..bd51f20 --- /dev/null +++ b/api/pluggableharness/model/v1/errors.proto @@ -0,0 +1,64 @@ +syntax = "proto3"; + +package pluggableharness.model.v1; + +import "google/protobuf/duration.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/model/proto/v1;modelv1"; + +// The model category's structured error taxonomy. + +// ModelErrorCategory classifies every StreamCompletion/Configure +// failure, per model.md §8. A plugin MUST classify every failure into +// exactly one of these categories and MUST NOT collapse them into a +// single generic error — the kernel's routing/fallback/retry behavior +// depends on telling these apart. +enum ModelErrorCategory { + // Zero value. Never valid on a real ModelError; its presence on the + // wire means a caller forgot to set the field. + MODEL_ERROR_CATEGORY_UNSPECIFIED = 0; + // The request (or accumulated conversation) exceeds the model's context + // window. The kernel MUST NOT blindly retry as-is. + MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED = 1; + // A vendor-side rate limit was hit. The kernel retries with backoff, + // honoring retry_after if supplied. + MODEL_ERROR_CATEGORY_RATE_LIMITED = 2; + // Transient vendor unavailability (5xx-equivalent). The kernel retries + // with backoff; a candidate for capability-aware fallback. + MODEL_ERROR_CATEGORY_OVERLOADED = 3; + // Bad, expired, or missing credentials. The kernel MUST NOT retry or + // silently fall back; this surfaces to a human. + MODEL_ERROR_CATEGORY_AUTH_ERROR = 4; + // A malformed request — almost always a kernel/adapter bug. The kernel + // MUST NOT retry as-is. + MODEL_ERROR_CATEGORY_INVALID_REQUEST = 5; + // The vendor refused or filtered the content. Surfaced distinctly from + // a generic failure so policy/UX can handle it differently. + MODEL_ERROR_CATEGORY_CONTENT_FILTERED = 6; + // Anything else. MUST include raw_detail for debugging; treated as + // non-retryable by default. + MODEL_ERROR_CATEGORY_UNKNOWN = 7; +} + +// ModelError is the structured error every failure crossing this +// plugin boundary carries, per model.md §8. +message ModelError { + // This failure's category. MUST be set. + ModelErrorCategory category = 1; + + // Human-readable description of the failure. + string message = 2; + + // Whether the kernel may retry this request as-is. + bool retryable = 3; + + // How long the kernel should wait before retrying, when the vendor + // supplies one (typically alongside MODEL_ERROR_CATEGORY_RATE_LIMITED). + // SHOULD be set when available. Refines model.md §8's + // "retry_after_seconds" into the native well-known type. + optional google.protobuf.Duration retry_after = 4; + + // The raw vendor-provided error code or body, for debugging. SHOULD be + // set. + optional string raw_detail = 5; +} diff --git a/api/pluggableharness/model/v1/events.proto b/api/pluggableharness/model/v1/events.proto new file mode 100644 index 0000000..cbe8e16 --- /dev/null +++ b/api/pluggableharness/model/v1/events.proto @@ -0,0 +1,133 @@ +syntax = "proto3"; + +package pluggableharness.model.v1; + +import "pluggableharness/model/v1/errors.proto"; +import "pluggableharness/model/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/model/proto/v1;modelv1"; + +// Occurrence-shaped messages streamed by ModelService.StreamCompletion. + +// StreamEvent is one message in the stream StreamCompletion returns, per +// model.md §4. Exactly one variant is set. +message StreamEvent { + oneof event { + // An incremental fragment of assistant text output. + TextDelta text_delta = 1; + // An incremental fragment of the model's reasoning output. + ThinkingDelta thinking_delta = 2; + // The vendor's opaque integrity token for the reasoning just emitted. + ThinkingSignature thinking_signature = 3; + // The model has begun requesting a tool invocation. + ToolCallStart tool_call_start = 4; + // An incremental fragment of a tool call's arguments. + ToolCallDelta tool_call_delta = 5; + // A tool call's arguments are complete. + ToolCallDone tool_call_done = 6; + // Token accounting for this completion. + Usage usage = 7; + // The completion has ended. + Stop stop = 8; + // The completion failed. + Error error = 9; + } + + // TextDelta carries one incremental fragment of assistant text output. + // MUST be supported by every plugin, both directions (model.md §5). + message TextDelta { + // The text fragment. + string text = 1; + } + + // ThinkingDelta carries one incremental fragment of the model's + // reasoning output. Only emitted when the target model's + // ThinkingSpec.supported is true. + message ThinkingDelta { + // The reasoning-text fragment. + string text = 1; + } + + // ThinkingSignature carries the vendor's opaque integrity token for the + // reasoning block just completed. MUST be emitted if the vendor's + // thinking blocks carry an integrity signature (model.md §4/§5); the + // kernel MUST store and round-trip this verbatim, never inspecting or + // reformatting it, into ContentBlock's ThinkingBlock.signature. + message ThinkingSignature { + // The opaque, vendor-specific signature bytes. + bytes signature = 1; + } + + // ToolCallStart announces the model has begun requesting a tool + // invocation. + message ToolCallStart { + // Correlation id for the matching ToolCallDelta/ToolCallDone events + // and the resulting ToolUseBlock.id. + string id = 1; + // The tool's declared name (ToolDeclaration.name). + string name = 2; + } + + // ToolCallDelta carries one incremental fragment of a tool call's + // arguments, accumulated by the kernel across deltas into the final + // parsed JSON. + message ToolCallDelta { + // The id from the matching ToolCallStart. + string id = 1; + // A partial-JSON fragment of the call's arguments. + string arguments_fragment = 2; + } + + // ToolCallDone signals a tool call's arguments are complete and ready + // for the kernel to parse and dispatch. + message ToolCallDone { + // The id from the matching ToolCallStart. + string id = 1; + } + + // Stop signals the completion has ended. + message Stop { + // Why the completion ended. + StopReason reason = 1; + + // Which GenerationParams.stop_sequences entry was matched. Set iff + // reason == STOP_REASON_STOP_SEQUENCE; MUST be omitted for every + // other reason. + optional string matched_stop_sequence = 2; + } + + // Error signals the completion failed. + message Error { + // The structured error, classified per model.md §8. + ModelError error = 1; + } +} + +// StopReason classifies why a StreamCompletion ended, per model.md §4. +enum StopReason { + // Zero value. Never valid on a real Stop event; its presence on the + // wire means a caller forgot to set the field. + STOP_REASON_UNSPECIFIED = 0; + // The model completed its turn normally. + STOP_REASON_END_TURN = 1; + // The model stopped to request one or more tool invocations. + STOP_REASON_TOOL_USE = 2; + // The model hit its output token limit before completing its turn. + STOP_REASON_MAX_TOKENS = 3; + // The vendor's content filter stopped generation. + STOP_REASON_CONTENT_FILTERED = 4; + // The kernel cancelled the stream (user interrupt, timeout, turn + // abort). MUST be treated by the plugin as normal control flow, never + // as an error (model.md §1, .claude/rules/grpc.md). + STOP_REASON_CANCELLED = 5; + // The model or vendor refused to continue generating — distinct from + // STOP_REASON_CONTENT_FILTERED, which is the vendor's automated content + // filter stopping generation; REFUSAL is the model itself declining + // (e.g. a safety-trained refusal message), a semantically different + // event even though both are policy-driven stops. + STOP_REASON_REFUSAL = 6; + // The model stopped because it generated one of + // GenerationParams.stop_sequences. Stop.matched_stop_sequence carries + // which one. + STOP_REASON_STOP_SEQUENCE = 7; +} diff --git a/api/pluggableharness/model/v1/rpc_request.proto b/api/pluggableharness/model/v1/rpc_request.proto new file mode 100644 index 0000000..ee76144 --- /dev/null +++ b/api/pluggableharness/model/v1/rpc_request.proto @@ -0,0 +1,121 @@ +syntax = "proto3"; + +package pluggableharness.model.v1; + +import "google/protobuf/struct.proto"; +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/content/v1/types.proto"; +import "pluggableharness/model/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/model/proto/v1;modelv1"; + +// Every message in a ModelService rpc's input position. + +// GetCapabilitiesRequest is empty: model.md §2 defines GetCapabilities +// as taking no request parameters. +message GetCapabilitiesRequest {} + +// ConfigureRequest wraps the provider's agent.hcl config block, already +// decoded from HCL/cty into a Struct by the kernel's schema-to-cty bridge, +// per model.md §3. +message ConfigureRequest { + // The decoded config value. Field contents are provider-specific (API + // key, base URL override, org/project IDs, etc.) — model.md §3 + // doesn't mandate a shape beyond what ConfigSchema (Capabilities. + // config_schema) declares. A Struct because the shape is genuinely + // provider-defined, not fixed at the proto level (see + // .claude/rules/proto.md's Struct carve-out). + google.protobuf.Struct config = 1; +} + +// DescribeRequest is empty: Describe takes no request parameters, per +// configuration/lock-file.md's dev_overrides note. +message DescribeRequest {} + +// StreamCompletionRequest is StreamCompletion's request: the full +// canonical conversation, available tools, and generation params for one +// completion, per model.md §4. +message StreamCompletionRequest { + // The canonical conversation history, in emission order (model.md + // §5). + repeated pluggableharness.content.v1.Message messages = 1; + + // Selects which of this provider's ModelSpec.id to use. + string model_id = 2; + + // The tools available to the model on this turn, described in the + // shared JSON-Schema subset (model.md §6). MAY be empty. + repeated ToolDeclaration tools = 3; + + // Generation-time overrides. Omitted means every param takes its + // model-specific default. + optional GenerationParams params = 4; + + // The kernel-assembled context chain — the accumulated output of every + // context provider's Contribute call plus memory recall + // (context/protocol.md#contribute-the-context-assemble-rpc), in chain + // order (the same order context.md's ContextRequest.prior_sections/ + // Contribute response chain uses: each provider appends after the + // sections it received). This is distinct from `messages` above: it is + // system-level/preamble content, never a conversational turn, which is + // why it's carried as its own field rather than folded into `messages` + // as a synthetic message — content.v1.Role deliberately has no SYSTEM + // value (content/v1/types.proto's Role comment) precisely because this content + // is never a Message with a role. Each model-provider adapter maps + // this chain to its vendor's own system/preamble mechanism (a top-level + // `system` string, a leading system-role message, etc.) — how that + // mapping happens is adapter-internal and not part of this wire + // contract. + repeated pluggableharness.content.v1.ContextSection assembled_context = 5; + + // Session/turn/working-directory attribution for this call. MUST be set + // by the kernel on every StreamCompletionRequest. This is what the + // plugin passes back on its own KernelCallbackService.Emit and Log + // calls (kernel-callbacks.md#emit) for correlation, without having to + // separately thread session_id/turn_id through adapter-internal call + // sites by hand. + pluggableharness.common.v1.CallContext call_context = 6; + + // Cache breakpoints for this request, wire-level and request-scoped — + // NOT carried on the persisted content.v1.ContentBlock, since a + // breakpoint's placement is a per-request optimization decision, not a + // durable property of the conversation history itself. Meaningful only + // when the target model's CachingSpec.mode == + // CACHING_MODE_EXPLICIT_MARKERS; a model-provider adapter targeting a + // model whose CachingSpec.mode is CACHING_MODE_IMPLICIT_AUTOMATIC or + // CACHING_MODE_NONE MUST ignore this field rather than error on it. + // Placement is a kernel decision, not the plugin's: the kernel knows + // each assembled_context section's Stability (content/v1/types.proto's Stability + // enum) and each message's position, so it places breakpoints at + // natural stable-prefix boundaries — see + // model/protocol.md#cache-breakpoint-placement-policy. + repeated CacheBreakpoint cache_breakpoints = 7; +} + +// CountTokensRequest is CountTokens' request: the raw text to count, per +// model.md §2.1. +message CountTokensRequest { + // The text to count tokens for. + string text = 1; + + // Selects which of this provider's ModelSpec.id to count against — a + // provider serving several models MAY have distinct tokenizers per + // model. MUST be set. + string model_id = 2; +} + +// RenderRequest carries the opaque payload to render, per model.md §7. +message RenderRequest { + // The opaque emitted payload to render — the Emit->Render->Paint + // pipeline's deliberate carve-out from the strong-typing rule (see + // .claude/rules/grpc.md), never interpreted by the kernel. + bytes payload = 1; + + // The schema_version `payload` was emitted under, per + // ../frontend/render-tree.md#schema-versioning. MUST be set — lets this + // Render implementation interpret a payload emitted by an older plugin + // version consistently across a replayed session, the same + // "supersedes" reasoning architecture.md applies elsewhere to + // schema-drift-sensitive persisted data. + string schema_version = 2; +} diff --git a/api/pluggableharness/model/v1/rpc_response.proto b/api/pluggableharness/model/v1/rpc_response.proto new file mode 100644 index 0000000..4b24212 --- /dev/null +++ b/api/pluggableharness/model/v1/rpc_response.proto @@ -0,0 +1,47 @@ +syntax = "proto3"; + +package pluggableharness.model.v1; + +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/model/v1/types.proto"; +import "pluggableharness/render/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/model/proto/v1;modelv1"; + +// Every message in an rpc's unary return position. + +// GetCapabilitiesResponse wraps Capabilities for the RPC signature, per +// this repo's per-RPC envelope convention (.claude/rules/proto.md). +message GetCapabilitiesResponse { + Capabilities capabilities = 1; +} + +// ConfigureResponse is empty on success. A Configure failure (e.g. a +// missing required field) surfaces as a gRPC status carrying a +// ModelError in its structured detail, per .claude/rules/grpc.md's +// error-taxonomy convention — there is no in-band error field on this +// message. +message ConfigureResponse {} + +// DescribeResponse reports this plugin build's own identity, per +// configuration/lock-file.md's dev_overrides note. +message DescribeResponse { + // This plugin build's identity, as it would otherwise appear in a + // lock-file provider "" { ... } entry. + pluggableharness.common.v1.ProducerRef producer = 1; +} + +// CountTokensResponse is CountTokens' response. +message CountTokensResponse { + // The exact token count, per this model's real vendor tokenizer. + int64 count = 1; +} + +// RenderResponse wraps the resulting RenderTree, per model.md §7. +message RenderResponse { + // The rendered tree, formally defined in frontend.md §1 and shared + // verbatim across every category's Render RPC (tool.md §7, context.md + // §9, memory.md §10) — one RenderTree type for the whole + // Emit->Render->Paint pipeline, not a per-category variant. + pluggableharness.render.v1.RenderTree tree = 1; +} diff --git a/api/pluggableharness/model/v1/service.proto b/api/pluggableharness/model/v1/service.proto new file mode 100644 index 0000000..6d6a341 --- /dev/null +++ b/api/pluggableharness/model/v1/service.proto @@ -0,0 +1,87 @@ +syntax = "proto3"; + +// Package pluggableharness.model.v1 defines the model (LLM vendor) provider +// plugin protocol described in specifications/model.md — see +// .claude/rules/proto.md — plus the two distinct model-identity shapes used +// across the other specs. The identity shapes are deliberately NOT unified +// into one message: ModelTarget is a rich "what am I generating context +// for" descriptor (context.md §4, memory.md §6); ModelRef is a narrow +// "which model's tokenizer" selector (kernel-callbacks.md §2). Merging them +// would force every CountTokens caller to populate fields it doesn't have +// and doesn't need. +package pluggableharness.model.v1; + +import "pluggableharness/model/v1/events.proto"; +import "pluggableharness/model/v1/rpc_request.proto"; +import "pluggableharness/model/v1/rpc_response.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/model/proto/v1;modelv1"; + +// ModelService is the model provider plugin protocol described in +// specifications/model.md §1: a subprocess + gRPC plugin (via +// hashicorp/go-plugin) fronting one LLM vendor. Every plugin exposes +// GetCapabilities, Configure, and StreamCompletion (all MUST); CountTokens +// SHOULD be implemented; Render MAY be implemented. +service ModelService { + // GetCapabilities returns one ModelSpec per model this plugin can serve, + // per model.md §2. MUST be cheap to call repeatedly (the kernel MAY + // call it before every routing decision) and MUST NOT require a network + // call to the vendor if avoidable. + rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); + + // Configure delivers the provider's agent.hcl config block, already + // decoded from HCL/cty into a Struct by the kernel's schema-to-cty + // bridge, per model.md §3. MUST reject missing required fields (e.g. + // no API key) with a structured error at Configure time rather than + // deferring the failure to the first StreamCompletion call. A plugin + // MUST NOT echo any received secret value into an Emit'd event, a Render + // output, a log line, or an error message. + rpc Configure(ConfigureRequest) returns (ConfigureResponse); + + // StreamCompletion is server-streaming, never bidirectional — this + // matches how a real vendor API actually works: one request in, one + // chunked/SSE response out. The kernel sends one request (full message + // history + tool declarations + generation params) and receives a + // stream of StreamEvents back. A backend whose vendor API is not natively + // streaming (batch-only) MUST still implement this RPC shape, emitting + // its full response as a single terminal burst of events followed by a + // `stop` event (model.md §4). Cancellation is the kernel closing the + // gRPC stream; the plugin MUST treat this as normal control flow — stop + // generating and release resources — surfacing StopReason + // STOP_REASON_CANCELLED, never treating it as an error condition + // (model.md §1, .claude/rules/grpc.md's cancellation rule). + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Stream element type is the bare "StreamEvent" per model.md §4's + // literal spec, naming the streamed domain concept rather than the RPC. + rpc StreamCompletion(StreamCompletionRequest) returns (stream StreamEvent); + + // CountTokens returns an exact token count for the given text, using the + // vendor's real tokenizer. SHOULD be implemented per model provider + // (model.md §2.1) — upgraded from an initial MAY per operator + // decision. A model provider that implements this gets its counts + // marked exact when the kernel resolves a CountTokens call against it + // (kernel-callbacks.md §2); a provider that doesn't falls back to the + // heuristic in kernel-callbacks.md §3, a genuine last resort. + rpc CountTokens(CountTokensRequest) returns (CountTokensResponse); + + // Render is the model-provider side of the Emit->Render->Paint pipeline + // (see docs/specifications/architecture.md), returning a RenderTree for an opaque + // emitted payload — e.g. to render a `thinking` block collapsed by + // default, or usage/cost info specially. MAY be implemented; model.md + // §7 notes most model-provider payloads (plain text, tool calls) render + // fine under the kernel's generic fallback when this RPC is absent. + rpc Render(RenderRequest) returns (RenderResponse); + + // Describe reports this plugin build's own identity — {name, version, + // source, category, protocol_version} — directly from the running + // process, rather than the kernel inferring it from a lock-file row. + // This matters specifically for a dev_overrides binary + // (configuration/settings-and-global.md#dev_overrides), which bypasses + // the registry/lock-file resolution path entirely and so has no + // provider "" { ... } lock entry to read identity from; see + // configuration/lock-file.md's dev_overrides note for the canonical + // explanation, shared verbatim across all seven category protocols that + // gain this RPC in this same protocol revision. + rpc Describe(DescribeRequest) returns (DescribeResponse); +} diff --git a/api/pluggableharness/model/v1/model.proto b/api/pluggableharness/model/v1/types.proto similarity index 50% rename from api/pluggableharness/model/v1/model.proto rename to api/pluggableharness/model/v1/types.proto index 29918e4..4a8c695 100644 --- a/api/pluggableharness/model/v1/model.proto +++ b/api/pluggableharness/model/v1/types.proto @@ -1,106 +1,15 @@ syntax = "proto3"; -// Package pluggableharness.model.v1 defines the model (LLM vendor) provider -// plugin protocol described in specifications/model.md — see -// .claude/rules/proto.md — plus the two distinct model-identity shapes used -// across the other specs. The identity shapes are deliberately NOT unified -// into one message: ModelTarget is a rich "what am I generating context -// for" descriptor (context.md §4, memory.md §6); ModelRef is a narrow -// "which model's tokenizer" selector (kernel-callbacks.md §2). Merging them -// would force every CountTokens caller to populate fields it doesn't have -// and doesn't need. package pluggableharness.model.v1; -import "google/protobuf/duration.proto"; -import "google/protobuf/struct.proto"; import "google/protobuf/timestamp.proto"; -import "pluggableharness/common/v1/common.proto"; -import "pluggableharness/config/v1/config.proto"; -import "pluggableharness/content/v1/content.proto"; -import "pluggableharness/render/v1/render.proto"; -import "pluggableharness/schema/v1/schema.proto"; -import "pluggableharness/slashcommand/v1/slashcommand.proto"; +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/config/v1/types.proto"; +import "pluggableharness/schema/v1/types.proto"; option go_package = "github.com/pluggableharness/agent/pkg/model/proto/v1;modelv1"; -// ModelService is the model provider plugin protocol described in -// specifications/model.md §1: a subprocess + gRPC plugin (via -// hashicorp/go-plugin) fronting one LLM vendor. Every plugin exposes -// GetCapabilities, Configure, and StreamCompletion (all MUST); CountTokens -// SHOULD be implemented; Render MAY be implemented. -service ModelService { - // GetCapabilities returns one ModelSpec per model this plugin can serve, - // per model.md §2. MUST be cheap to call repeatedly (the kernel MAY - // call it before every routing decision) and MUST NOT require a network - // call to the vendor if avoidable. - rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); - - // Configure delivers the provider's agent.hcl config block, already - // decoded from HCL/cty into a Struct by the kernel's schema-to-cty - // bridge, per model.md §3. MUST reject missing required fields (e.g. - // no API key) with a structured error at Configure time rather than - // deferring the failure to the first StreamCompletion call. A plugin - // MUST NOT echo any received secret value into an Emit'd event, a Render - // output, a log line, or an error message. - rpc Configure(ConfigureRequest) returns (ConfigureResponse); - - // StreamCompletion is server-streaming, never bidirectional — this - // matches how a real vendor API actually works: one request in, one - // chunked/SSE response out. The kernel sends one request (full message - // history + tool declarations + generation params) and receives a - // stream of StreamEvents back. A backend whose vendor API is not natively - // streaming (batch-only) MUST still implement this RPC shape, emitting - // its full response as a single terminal burst of events followed by a - // `stop` event (model.md §4). Cancellation is the kernel closing the - // gRPC stream; the plugin MUST treat this as normal control flow — stop - // generating and release resources — surfacing StopReason - // STOP_REASON_CANCELLED, never treating it as an error condition - // (model.md §1, .claude/rules/grpc.md's cancellation rule). - // - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Stream element type is the bare "StreamEvent" per model.md §4's - // literal spec, naming the streamed domain concept rather than the RPC. - rpc StreamCompletion(StreamCompletionRequest) returns (stream StreamEvent); - - // CountTokens returns an exact token count for the given text, using the - // vendor's real tokenizer. SHOULD be implemented per model provider - // (model.md §2.1) — upgraded from an initial MAY per operator - // decision. A model provider that implements this gets its counts - // marked exact when the kernel resolves a CountTokens call against it - // (kernel-callbacks.md §2); a provider that doesn't falls back to the - // heuristic in kernel-callbacks.md §3, a genuine last resort. - rpc CountTokens(CountTokensRequest) returns (CountTokensResponse); - - // Render is the model-provider side of the Emit->Render->Paint pipeline - // (see docs/specifications/architecture.md), returning a RenderTree for an opaque - // emitted payload — e.g. to render a `thinking` block collapsed by - // default, or usage/cost info specially. MAY be implemented; model.md - // §7 notes most model-provider payloads (plain text, tool calls) render - // fine under the kernel's generic fallback when this RPC is absent. - rpc Render(RenderRequest) returns (RenderResponse); - - // Describe reports this plugin build's own identity — {name, version, - // source, category, protocol_version} — directly from the running - // process, rather than the kernel inferring it from a lock-file row. - // This matters specifically for a dev_overrides binary - // (configuration/settings-and-global.md#dev_overrides), which bypasses - // the registry/lock-file resolution path entirely and so has no - // provider "" { ... } lock entry to read identity from; see - // configuration/lock-file.md's dev_overrides note for the canonical - // explanation, shared verbatim across all six category protocols that - // gain this RPC in this same protocol revision. - rpc Describe(DescribeRequest) returns (DescribeResponse); -} - -// GetCapabilitiesRequest is empty: model.md §2 defines GetCapabilities -// as taking no request parameters. -message GetCapabilitiesRequest {} - -// GetCapabilitiesResponse wraps Capabilities for the RPC signature, per -// this repo's per-RPC envelope convention (.claude/rules/proto.md). -message GetCapabilitiesResponse { - Capabilities capabilities = 1; -} +// Domain messages and enums shared across ModelService's requests, responses, and events. // Capabilities is GetCapabilities' response payload: every model this // plugin can serve, plus provider-wide declarations that apply once, not @@ -110,10 +19,12 @@ message Capabilities { // entry. repeated ModelSpec models = 1; - // Slash commands this provider contributes, declared once for the - // provider as a whole (not per model), per model.md §2 and - // configuration.md §5 / frontend.md §5. MAY be empty. - repeated pluggableharness.slashcommand.v1.SlashCommandSpec slash_commands = 2; + // Prompt-expansion slash commands this provider contributes, declared + // once for the provider as a whole (not per model), per model.md §2 + // and configuration.md §5 / frontend.md §5. MAY be empty. A + // direct-invoke command is declared by a slashcommand.v1 provider + // instead (specifications/slashcommand/), never here. + repeated pluggableharness.common.v1.PromptExpansionSpec slash_commands = 2; // The provider's agent.hcl config schema, returned alongside // capabilities so the kernel knows what fields Configure expects, per @@ -125,48 +36,16 @@ message Capabilities { // agent.hcl hook{} block naming a point not present here, at // config-load time. // - // Typed as common.v1.HookPoint, not hook.v1.HookPoint: hook.proto - // imports model.proto (for ModelRef/Usage on its PreModelCall/ - // PostModelResponse hook payloads), so model.proto importing hook.proto - // for this field would be a cyclic file dependency — confirmed via + // Typed as common.v1.HookPoint, not hook.v1.HookPoint: hook/v1/events.proto + // imports model/v1/types.proto (for ModelRef/Usage on its PreModelCall/ + // PostModelResponse hook payloads), so model/v1/types.proto importing + // anything from hook.v1 for this field would be a cyclic file dependency — confirmed via // `buf build`, which rejects it outright ("detected cyclic import"). // HookPoint itself lives in common.v1 for exactly this reason (see - // common.proto), already imported here for CallContext/Describe. + // common/v1/types.proto), already imported here for CallContext/Describe. repeated pluggableharness.common.v1.HookPoint supported_hook_points = 4; } -// ConfigureRequest wraps the provider's agent.hcl config block, already -// decoded from HCL/cty into a Struct by the kernel's schema-to-cty bridge, -// per model.md §3. -message ConfigureRequest { - // The decoded config value. Field contents are provider-specific (API - // key, base URL override, org/project IDs, etc.) — model.md §3 - // doesn't mandate a shape beyond what ConfigSchema (Capabilities. - // config_schema) declares. A Struct because the shape is genuinely - // provider-defined, not fixed at the proto level (see - // .claude/rules/proto.md's Struct carve-out). - google.protobuf.Struct config = 1; -} - -// ConfigureResponse is empty on success. A Configure failure (e.g. a -// missing required field) surfaces as a gRPC status carrying a -// ModelError in its structured detail, per .claude/rules/grpc.md's -// error-taxonomy convention — there is no in-band error field on this -// message. -message ConfigureResponse {} - -// DescribeRequest is empty: Describe takes no request parameters, per -// configuration/lock-file.md's dev_overrides note. -message DescribeRequest {} - -// DescribeResponse reports this plugin build's own identity, per -// configuration/lock-file.md's dev_overrides note. -message DescribeResponse { - // This plugin build's identity, as it would otherwise appear in a - // lock-file provider "" { ... } entry. - pluggableharness.common.v1.ProducerRef producer = 1; -} - // ModelSpec describes one model this provider can serve, per // model.md §2. Every field below is MUST unless its comment says // otherwise. @@ -411,66 +290,6 @@ message Pricing { repeated PricingTier tiers = 3; } -// StreamCompletionRequest is StreamCompletion's request: the full -// canonical conversation, available tools, and generation params for one -// completion, per model.md §4. -message StreamCompletionRequest { - // The canonical conversation history, in emission order (model.md - // §5). - repeated pluggableharness.content.v1.Message messages = 1; - - // Selects which of this provider's ModelSpec.id to use. - string model_id = 2; - - // The tools available to the model on this turn, described in the - // shared JSON-Schema subset (model.md §6). MAY be empty. - repeated ToolDeclaration tools = 3; - - // Generation-time overrides. Omitted means every param takes its - // model-specific default. - optional GenerationParams params = 4; - - // The kernel-assembled context chain — the accumulated output of every - // context provider's Contribute call plus memory recall - // (context/protocol.md#contribute-the-context-assemble-rpc), in chain - // order (the same order context.md's ContextRequest.prior_sections/ - // Contribute response chain uses: each provider appends after the - // sections it received). This is distinct from `messages` above: it is - // system-level/preamble content, never a conversational turn, which is - // why it's carried as its own field rather than folded into `messages` - // as a synthetic message — content.v1.Role deliberately has no SYSTEM - // value (content.proto's Role comment) precisely because this content - // is never a Message with a role. Each model-provider adapter maps - // this chain to its vendor's own system/preamble mechanism (a top-level - // `system` string, a leading system-role message, etc.) — how that - // mapping happens is adapter-internal and not part of this wire - // contract. - repeated pluggableharness.content.v1.ContextSection assembled_context = 5; - - // Session/turn/working-directory attribution for this call. MUST be set - // by the kernel on every StreamCompletionRequest. This is what the - // plugin passes back on its own KernelCallbackService.Emit and Log - // calls (kernel-callbacks.md#emit) for correlation, without having to - // separately thread session_id/turn_id through adapter-internal call - // sites by hand. - pluggableharness.common.v1.CallContext call_context = 6; - - // Cache breakpoints for this request, wire-level and request-scoped — - // NOT carried on the persisted content.v1.ContentBlock, since a - // breakpoint's placement is a per-request optimization decision, not a - // durable property of the conversation history itself. Meaningful only - // when the target model's CachingSpec.mode == - // CACHING_MODE_EXPLICIT_MARKERS; a model-provider adapter targeting a - // model whose CachingSpec.mode is CACHING_MODE_IMPLICIT_AUTOMATIC or - // CACHING_MODE_NONE MUST ignore this field rather than error on it. - // Placement is a kernel decision, not the plugin's: the kernel knows - // each assembled_context section's Stability (content.proto's Stability - // enum) and each message's position, so it places breakpoints at - // natural stable-prefix boundaries — see - // model/protocol.md#cache-breakpoint-placement-policy. - repeated CacheBreakpoint cache_breakpoints = 7; -} - // CacheBreakpoint marks one position in a StreamCompletionRequest where // the kernel wants the adapter to insert a vendor-native cache-control // marker, per StreamCompletionRequest.cache_breakpoints above. @@ -588,100 +407,6 @@ message ToolChoice { optional string tool_name = 2; } -// StreamEvent is one message in the stream StreamCompletion returns, per -// model.md §4. Exactly one variant is set. -message StreamEvent { - oneof event { - // An incremental fragment of assistant text output. - TextDelta text_delta = 1; - // An incremental fragment of the model's reasoning output. - ThinkingDelta thinking_delta = 2; - // The vendor's opaque integrity token for the reasoning just emitted. - ThinkingSignature thinking_signature = 3; - // The model has begun requesting a tool invocation. - ToolCallStart tool_call_start = 4; - // An incremental fragment of a tool call's arguments. - ToolCallDelta tool_call_delta = 5; - // A tool call's arguments are complete. - ToolCallDone tool_call_done = 6; - // Token accounting for this completion. - Usage usage = 7; - // The completion has ended. - Stop stop = 8; - // The completion failed. - Error error = 9; - } - - // TextDelta carries one incremental fragment of assistant text output. - // MUST be supported by every plugin, both directions (model.md §5). - message TextDelta { - // The text fragment. - string text = 1; - } - - // ThinkingDelta carries one incremental fragment of the model's - // reasoning output. Only emitted when the target model's - // ThinkingSpec.supported is true. - message ThinkingDelta { - // The reasoning-text fragment. - string text = 1; - } - - // ThinkingSignature carries the vendor's opaque integrity token for the - // reasoning block just completed. MUST be emitted if the vendor's - // thinking blocks carry an integrity signature (model.md §4/§5); the - // kernel MUST store and round-trip this verbatim, never inspecting or - // reformatting it, into ContentBlock's ThinkingBlock.signature. - message ThinkingSignature { - // The opaque, vendor-specific signature bytes. - bytes signature = 1; - } - - // ToolCallStart announces the model has begun requesting a tool - // invocation. - message ToolCallStart { - // Correlation id for the matching ToolCallDelta/ToolCallDone events - // and the resulting ToolUseBlock.id. - string id = 1; - // The tool's declared name (ToolDeclaration.name). - string name = 2; - } - - // ToolCallDelta carries one incremental fragment of a tool call's - // arguments, accumulated by the kernel across deltas into the final - // parsed JSON. - message ToolCallDelta { - // The id from the matching ToolCallStart. - string id = 1; - // A partial-JSON fragment of the call's arguments. - string arguments_fragment = 2; - } - - // ToolCallDone signals a tool call's arguments are complete and ready - // for the kernel to parse and dispatch. - message ToolCallDone { - // The id from the matching ToolCallStart. - string id = 1; - } - - // Stop signals the completion has ended. - message Stop { - // Why the completion ended. - StopReason reason = 1; - - // Which GenerationParams.stop_sequences entry was matched. Set iff - // reason == STOP_REASON_STOP_SEQUENCE; MUST be omitted for every - // other reason. - optional string matched_stop_sequence = 2; - } - - // Error signals the completion failed. - message Error { - // The structured error, classified per model.md §8. - ModelError error = 1; - } -} - // Usage carries token accounting for one completion, per model.md §4.1. // The kernel computes and persists cost_usd from these counts plus the // matching PricingTier — the plugin never computes cost itself. Promoted @@ -713,133 +438,6 @@ message Usage { // model.md §4.1 — the provider never computes cost itself. } -// StopReason classifies why a StreamCompletion ended, per model.md §4. -enum StopReason { - // Zero value. Never valid on a real Stop event; its presence on the - // wire means a caller forgot to set the field. - STOP_REASON_UNSPECIFIED = 0; - // The model completed its turn normally. - STOP_REASON_END_TURN = 1; - // The model stopped to request one or more tool invocations. - STOP_REASON_TOOL_USE = 2; - // The model hit its output token limit before completing its turn. - STOP_REASON_MAX_TOKENS = 3; - // The vendor's content filter stopped generation. - STOP_REASON_CONTENT_FILTERED = 4; - // The kernel cancelled the stream (user interrupt, timeout, turn - // abort). MUST be treated by the plugin as normal control flow, never - // as an error (model.md §1, .claude/rules/grpc.md). - STOP_REASON_CANCELLED = 5; - // The model or vendor refused to continue generating — distinct from - // STOP_REASON_CONTENT_FILTERED, which is the vendor's automated content - // filter stopping generation; REFUSAL is the model itself declining - // (e.g. a safety-trained refusal message), a semantically different - // event even though both are policy-driven stops. - STOP_REASON_REFUSAL = 6; - // The model stopped because it generated one of - // GenerationParams.stop_sequences. Stop.matched_stop_sequence carries - // which one. - STOP_REASON_STOP_SEQUENCE = 7; -} - -// CountTokensRequest is CountTokens' request: the raw text to count, per -// model.md §2.1. -message CountTokensRequest { - // The text to count tokens for. - string text = 1; - - // Selects which of this provider's ModelSpec.id to count against — a - // provider serving several models MAY have distinct tokenizers per - // model. MUST be set. - string model_id = 2; -} - -// CountTokensResponse is CountTokens' response. -message CountTokensResponse { - // The exact token count, per this model's real vendor tokenizer. - int64 count = 1; -} - -// RenderRequest carries the opaque payload to render, per model.md §7. -message RenderRequest { - // The opaque emitted payload to render — the Emit->Render->Paint - // pipeline's deliberate carve-out from the strong-typing rule (see - // .claude/rules/grpc.md), never interpreted by the kernel. - bytes payload = 1; - - // The schema_version `payload` was emitted under, per - // ../frontend/render-tree.md#schema-versioning. MUST be set — lets this - // Render implementation interpret a payload emitted by an older plugin - // version consistently across a replayed session, the same - // "supersedes" reasoning architecture.md applies elsewhere to - // schema-drift-sensitive persisted data. - string schema_version = 2; -} - -// RenderResponse wraps the resulting RenderTree, per model.md §7. -message RenderResponse { - // The rendered tree, formally defined in frontend.md §1 and shared - // verbatim across every category's Render RPC (tool.md §7, context.md - // §9, memory.md §10) — one RenderTree type for the whole - // Emit->Render->Paint pipeline, not a per-category variant. - pluggableharness.render.v1.RenderTree tree = 1; -} - -// ModelErrorCategory classifies every StreamCompletion/Configure -// failure, per model.md §8. A plugin MUST classify every failure into -// exactly one of these categories and MUST NOT collapse them into a -// single generic error — the kernel's routing/fallback/retry behavior -// depends on telling these apart. -enum ModelErrorCategory { - // Zero value. Never valid on a real ModelError; its presence on the - // wire means a caller forgot to set the field. - MODEL_ERROR_CATEGORY_UNSPECIFIED = 0; - // The request (or accumulated conversation) exceeds the model's context - // window. The kernel MUST NOT blindly retry as-is. - MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED = 1; - // A vendor-side rate limit was hit. The kernel retries with backoff, - // honoring retry_after if supplied. - MODEL_ERROR_CATEGORY_RATE_LIMITED = 2; - // Transient vendor unavailability (5xx-equivalent). The kernel retries - // with backoff; a candidate for capability-aware fallback. - MODEL_ERROR_CATEGORY_OVERLOADED = 3; - // Bad, expired, or missing credentials. The kernel MUST NOT retry or - // silently fall back; this surfaces to a human. - MODEL_ERROR_CATEGORY_AUTH_ERROR = 4; - // A malformed request — almost always a kernel/adapter bug. The kernel - // MUST NOT retry as-is. - MODEL_ERROR_CATEGORY_INVALID_REQUEST = 5; - // The vendor refused or filtered the content. Surfaced distinctly from - // a generic failure so policy/UX can handle it differently. - MODEL_ERROR_CATEGORY_CONTENT_FILTERED = 6; - // Anything else. MUST include raw_detail for debugging; treated as - // non-retryable by default. - MODEL_ERROR_CATEGORY_UNKNOWN = 7; -} - -// ModelError is the structured error every failure crossing this -// plugin boundary carries, per model.md §8. -message ModelError { - // This failure's category. MUST be set. - ModelErrorCategory category = 1; - - // Human-readable description of the failure. - string message = 2; - - // Whether the kernel may retry this request as-is. - bool retryable = 3; - - // How long the kernel should wait before retrying, when the vendor - // supplies one (typically alongside MODEL_ERROR_CATEGORY_RATE_LIMITED). - // SHOULD be set when available. Refines model.md §8's - // "retry_after_seconds" into the native well-known type. - optional google.protobuf.Duration retry_after = 4; - - // The raw vendor-provided error code or body, for debugging. SHOULD be - // set. - optional string raw_detail = 5; -} - // ModelTarget describes the model a context or memory contribution is // being assembled for, derived from that model's ModelSpec // (model.md §2). Carried on context.md's ContextRequest and memory.md's diff --git a/api/pluggableharness/plan/v1/plan.proto b/api/pluggableharness/plan/v1/types.proto similarity index 62% rename from api/pluggableharness/plan/v1/plan.proto rename to api/pluggableharness/plan/v1/types.proto index aaf6d69..997d039 100644 --- a/api/pluggableharness/plan/v1/plan.proto +++ b/api/pluggableharness/plan/v1/types.proto @@ -2,16 +2,19 @@ syntax = "proto3"; // Package pluggableharness.plan.v1 defines the plan/apply gate's data types // (specifications/agent-loop.md §5.1). A Plan collects every resource call -// identified in a turn; each PlanItem is evaluated independently against -// policy (agent-loop.md §5.1: three resource calls against three -// different tool providers MUST receive three independently evaluated -// decisions, even if presentation batches them into one approval UI -// interaction). +// identified in a turn — from a tool.v1 provider or a slashcommand.v1 +// provider alike, see PlanItem.producer_category — and each PlanItem is +// evaluated independently against policy (agent-loop.md §5.1: three +// resource calls against three different providers MUST receive three +// independently evaluated decisions, even if presentation batches them +// into one approval UI interaction). package pluggableharness.plan.v1; import "google/protobuf/struct.proto"; -import "pluggableharness/render/v1/render.proto"; -import "pluggableharness/tool/v1/tool.proto"; +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/render/v1/types.proto"; +import "pluggableharness/tool/v1/errors.proto"; +import "pluggableharness/tool/v1/types.proto"; option go_package = "github.com/pluggableharness/agent/pkg/plan/proto/v1;planv1"; @@ -42,24 +45,34 @@ enum PlanDecision { } // PlanItem is one resource (or policy-checked data_source/interactive) -// call awaiting or having received a plan/apply decision. +// call awaiting or having received a plan/apply decision. Produced by +// either a tool.v1 provider (a ToolCall) or a slashcommand.v1 provider +// (a SlashCommandCall) — both flow through the identical plan/apply +// gate, so this message is deliberately provider-category-agnostic +// rather than tool-exclusive; `producer_category` below is what tells a +// consumer which one produced a given item. message PlanItem { // This item's own id, stable within the plan. string id = 1; - // The originating tool call's id (matches pluggableharness.content.v1 - // ToolUseBlock.id / pluggableharness.tool.v1 ToolCall.id). - string tool_call_id = 2; + // The originating call's id (matches pluggableharness.content.v1 + // ToolUseBlock.id, and — depending on producer_category — either + // pluggableharness.tool.v1 ToolCall.id or + // pluggableharness.slashcommand.v1 SlashCommandCall.id). + string call_id = 2; - // The declared name of the tool provider plugin this call targets. + // The declared name of the provider plugin this call targets. string provider = 3; - // The tool operation being called (tool.md §2 ToolSchema.name). - string tool_name = 4; + // The operation being called: a pluggableharness.tool.v1.ToolSchema.name + // when producer_category == CATEGORY_TOOL, or a + // pluggableharness.slashcommand.v1.SlashCommandSpec.name when + // producer_category == CATEGORY_SLASHCOMMAND. + string operation_name = 4; - // The call's parsed arguments — the kernel's canonical ToolCall - // representation (model.md §6 / pluggableharness.schema.v1's subset governs - // its shape). A Struct per .claude/rules/proto.md's runtime-JSON + // The call's parsed arguments — the kernel's canonical representation + // (model.md §6 / pluggableharness.schema.v1's subset governs its + // shape). A Struct per .claude/rules/proto.md's runtime-JSON // carve-out. google.protobuf.Struct input = 5; @@ -72,34 +85,48 @@ message PlanItem { // --- Snapshot fields (agent-loop/plan-apply-gate.md#plan-construction-and-policy-evaluation) --- // - // The four fields below are captured from the originating tool - // operation's ToolSchema at plan-construction time, not looked up live - // at display or audit time. A later agent.hcl change to the provider's - // schema (a re-classified kind/risk, an edited description) MUST NOT + // The four fields below are captured from the originating operation's + // schema (ToolSchema or SlashCommandSpec, per producer_category) at + // plan-construction time, not looked up live at display or audit + // time. A later agent.hcl change to the provider's schema (a + // re-classified kind/risk, an edited description) MUST NOT // retroactively alter a historical plan's audit record — the snapshot // is what a frontend renders and what state-backend.md's plan_items // table persists, independent of whatever the schema looks like now. - // Snapshot of ToolSchema.kind (tool/data-types.md#toolschema) for the - // operation this item calls, at plan-construction time. + // Snapshot of the originating operation's kind (ToolSchema.kind or + // SlashCommandSpec.kind — the same pluggableharness.tool.v1.ToolKind + // type either way, per tool/data-types.md#toolschema), at + // plan-construction time. pluggableharness.tool.v1.ToolKind kind = 8; - // Snapshot of ToolSchema.risk, at plan-construction time. + // Snapshot of the originating operation's risk (ToolSchema.risk or + // SlashCommandSpec.risk — the same pluggableharness.tool.v1.RiskClass + // type either way), at plan-construction time. pluggableharness.tool.v1.RiskClass risk = 9; - // Snapshot of ToolSchema.description, at plan-construction time. + // Snapshot of the originating operation's description, at + // plan-construction time. string description = 10; - // The tool provider's dry-run preview of this call's effect, when the - // provider implements ToolService.Preview (tool/protocol.md#preview). - // The kernel calls Preview at plan-construction time for + // The provider's dry-run preview of this call's effect, when the + // provider implements Preview (tool/protocol.md#preview or + // slashcommand/protocol.md#preview, per producer_category). The + // kernel calls Preview at plan-construction time for // TOOL_KIND_RESOURCE items whose provider implements it; absent when // the provider has no Preview implementation, in which case a frontend // falls back to rendering the raw `input` above. Pinned to - // render.v1.RenderTree — the exact type ToolService.Preview's own + // render.v1.RenderTree — the exact type either category's Preview // response carries, so this stored snapshot and the RPC's live output // share one wire shape (agent-loop/plan-apply-gate.md#preview-flow). optional pluggableharness.render.v1.RenderTree preview = 11; + + // Which category produced this item — CATEGORY_TOOL or + // CATEGORY_SLASHCOMMAND today, though nothing about this message + // assumes only those two will ever reach the plan/apply gate. Governs + // which category's Invoke/Preview RPC the kernel calls at apply time + // and how `call_id`/`operation_name` above should be interpreted. + pluggableharness.common.v1.Category producer_category = 12; } // Plan collects every policy-evaluated call identified during one turn. @@ -132,9 +159,9 @@ message ApplyResult { // The originating PlanItem.id this outcome is for. MUST be set. string plan_item_id = 1; - // The originating PlanItem.tool_call_id this outcome is for. MUST - // be set. - string tool_call_id = 2; + // The originating PlanItem.call_id this outcome is for. MUST be + // set. + string call_id = 2; // How this item's apply attempt concluded. MUST be set (never // APPLY_OUTCOME_UNSPECIFIED). diff --git a/api/pluggableharness/render/v1/render.proto b/api/pluggableharness/render/v1/types.proto similarity index 100% rename from api/pluggableharness/render/v1/render.proto rename to api/pluggableharness/render/v1/types.proto diff --git a/api/pluggableharness/schema/v1/schema.proto b/api/pluggableharness/schema/v1/types.proto similarity index 100% rename from api/pluggableharness/schema/v1/schema.proto rename to api/pluggableharness/schema/v1/types.proto diff --git a/api/pluggableharness/session/v1/session.proto b/api/pluggableharness/session/v1/types.proto similarity index 100% rename from api/pluggableharness/session/v1/session.proto rename to api/pluggableharness/session/v1/types.proto diff --git a/api/pluggableharness/slashcommand/v1/events.proto b/api/pluggableharness/slashcommand/v1/events.proto new file mode 100644 index 0000000..e350eb0 --- /dev/null +++ b/api/pluggableharness/slashcommand/v1/events.proto @@ -0,0 +1,95 @@ +syntax = "proto3"; + +package pluggableharness.slashcommand.v1; + +import "google/protobuf/struct.proto"; +import "pluggableharness/tool/v1/errors.proto"; +import "pluggableharness/tool/v1/events.proto"; +import "pluggableharness/tool/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1;slashcommandv1"; + +// Occurrence-shaped messages streamed by Invoke. + +// InvokeResponse wraps one message in the stream Invoke returns. A thin +// per-RPC envelope around SlashCommandEvent, which keeps its own rich +// structure independent of the RPC signature. +message InvokeResponse { + // The event. + SlashCommandEvent event = 1; +} + +// SlashCommandEvent is one message in the stream Invoke returns, +// structurally identical to pluggableharness.tool.v1.ToolEvent — the +// same streaming contract applies verbatim (exactly one of +// `result`/`error` closes the stream; `output_chunk`, `progress`, and +// `partial_result` MAY each appear zero or more times before it; +// `exit_status` MAY appear at most once; the relative order of +// `output_chunk` events MUST be preserved by the transport). The +// terminal result/error and the output-stream discriminator are the +// same pluggableharness.tool.v1 types tool.v1.ToolEvent uses, reused +// here rather than redeclared — a direct-invoke command's result is the +// same kind of thing a tool call's result is. +message SlashCommandEvent { + oneof event { + // Incremental raw output from a process-backed command. + OutputChunk output_chunk = 1; + + // A human-readable progress update. + Progress progress = 2; + + // Incremental structured output, e.g. search hits as they're found. + PartialResult partial_result = 3; + + // The exit status of a process-backed command's child process. + ExitStatus exit_status = 4; + + // The terminal, successful result of this call. + pluggableharness.tool.v1.ToolResult result = 5; + + // The terminal, failed result of this call. + pluggableharness.tool.v1.ToolError error = 6; + } + + // OutputChunk carries one slice of raw stdout/stderr-shaped output + // from a process-backed command. + message OutputChunk { + // Which stream this chunk came from. + pluggableharness.tool.v1.OutputStream stream = 1; + + // The chunk's raw bytes. + bytes data = 2; + } + + // Progress carries a human-readable status update for a long-running + // call. + message Progress { + // A human-readable description of the current step. + string message = 1; + + // How far through the operation this call is, in [0.0, 1.0]. Absent + // means the provider cannot estimate completion fraction. + optional double fraction_complete = 2; + } + + // PartialResult carries incremental structured output before the + // terminal result, e.g. search hits as they're found. + message PartialResult { + // The incremental structured payload. + google.protobuf.Struct payload = 1; + } + + // ExitStatus carries a process-backed command's child process exit + // information. Only meaningful for a command whose implementation + // shells out — a command with no child process MUST NOT emit this. + // Appears at most once per Invoke stream. + message ExitStatus { + // The child process's exit code. + int32 exit_code = 1; + + // The signal that terminated the child process, if any. Absent + // means the process exited normally (exit_code is meaningful on its + // own). + optional string signal = 2; + } +} diff --git a/api/pluggableharness/slashcommand/v1/rpc_request.proto b/api/pluggableharness/slashcommand/v1/rpc_request.proto new file mode 100644 index 0000000..adaac52 --- /dev/null +++ b/api/pluggableharness/slashcommand/v1/rpc_request.proto @@ -0,0 +1,55 @@ +syntax = "proto3"; + +package pluggableharness.slashcommand.v1; + +import "google/protobuf/struct.proto"; +import "pluggableharness/slashcommand/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1;slashcommandv1"; + +// Request messages for the slashcommand provider protocol's RPCs. + +// GetCapabilitiesRequest carries no fields — GetCapabilities takes no +// parameters. +message GetCapabilitiesRequest {} + +// ConfigureRequest wraps this provider's already-decoded agent.hcl +// config. +message ConfigureRequest { + // The provider-specific config, decoded from agent.hcl via the + // schema-to-cty bridge before crossing the wire. + google.protobuf.Struct config = 1; +} + +// InvokeRequest wraps the call to execute. A thin per-RPC envelope +// around SlashCommandCall, which keeps its own rich structure +// independent of the RPC signature. +message InvokeRequest { + // The call to execute. + SlashCommandCall call = 1; +} + +// RenderRequest carries the opaque payload to render. See grpc.md's +// Emit->Render->Paint carve-out for why this field stays `bytes` rather +// than a strongly-typed message. +message RenderRequest { + // The opaque emitted payload to render. + bytes payload = 1; + + // The schema version the payload was emitted under, per + // ../frontend/render-tree.md#schema-versioning. Lets a Render + // implementation decode a payload emitted by an older build of this + // same plugin. + string schema_version = 2; +} + +// PreviewRequest wraps the call to describe, per +// slashcommand/protocol.md#preview. +message PreviewRequest { + // The call Preview describes a dry run of. Same shape as an Invoke + // request; Preview MUST NOT execute it. + SlashCommandCall call = 1; +} + +// DescribeRequest carries no fields — Describe takes no parameters. +message DescribeRequest {} diff --git a/api/pluggableharness/slashcommand/v1/rpc_response.proto b/api/pluggableharness/slashcommand/v1/rpc_response.proto new file mode 100644 index 0000000..e09cb82 --- /dev/null +++ b/api/pluggableharness/slashcommand/v1/rpc_response.proto @@ -0,0 +1,69 @@ +syntax = "proto3"; + +package pluggableharness.slashcommand.v1; + +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/config/v1/types.proto"; +import "pluggableharness/render/v1/types.proto"; +import "pluggableharness/slashcommand/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1;slashcommandv1"; + +// Unary response messages for the slashcommand provider protocol's RPCs. + +// GetCapabilitiesResponse is this provider's complete capability +// advertisement. +message GetCapabilitiesResponse { + // One entry per direct-invoke command this plugin exposes. + repeated SlashCommandSpec commands = 1; + + // This provider's agent.hcl config schema, per configuration.md §4 — + // what fields Configure's request may be decoded from. + pluggableharness.config.v1.ConfigSchema config_schema = 2; + + // Which of the eight dispatchable hook points (common.v1.HookPoint) + // this provider subscribes a HookSubscriberService to, per this + // provider's own agent.hcl hook{} blocks. Lets the kernel validate a + // hook{} declaration at config-load time instead of discovering an + // unsupported subscription only when that hook point first fires. + repeated pluggableharness.common.v1.HookPoint supported_hook_points = 3; +} + +// ConfigureResponse is empty on success. Errors surface as a gRPC status +// carrying a pluggableharness.tool.v1.ToolError in its detail, per +// grpc.md — not an in-band field here. +message ConfigureResponse {} + +// RenderResponse wraps the rendered tree. A thin per-RPC envelope around +// RenderTree, which keeps its own rich structure independent of the RPC +// signature. +message RenderResponse { + // The rendered tree. + pluggableharness.render.v1.RenderTree tree = 1; +} + +// PreviewResponse carries a dry-run, human-readable description of what +// Invoke(call) would do, per slashcommand/protocol.md#preview. Rendered +// into the plan/apply gate's permission UI via PlanItem.preview +// (pluggableharness.plan.v1) — that field and this response share the +// same pluggableharness.render.v1.RenderTree type by design, exactly as +// tool.v1.PreviewResponse's does for a tool call. +message PreviewResponse { + // The dry-run preview, rendered as a RenderTree. Producing this MUST + // NOT mutate anything and MUST be side-effect-free — the same + // guarantee a TOOL_KIND_DATA_SOURCE operation makes, but + // unconditionally, regardless of the call's actual + // pluggableharness.tool.v1.ToolKind. + pluggableharness.render.v1.RenderTree preview = 1; +} + +// DescribeResponse reports this plugin build's own identity, per +// configuration/lock-file.md's dev_overrides note: a +// dev_overrides-resolved plugin has no lock-file entry for the kernel to +// read {name, version, source, category, protocol_version} from, so the +// kernel obtains it directly from the running process via this RPC +// instead. +message DescribeResponse { + // This plugin build's identity. + pluggableharness.common.v1.ProducerRef producer = 1; +} diff --git a/api/pluggableharness/slashcommand/v1/service.proto b/api/pluggableharness/slashcommand/v1/service.proto new file mode 100644 index 0000000..9f42d7c --- /dev/null +++ b/api/pluggableharness/slashcommand/v1/service.proto @@ -0,0 +1,68 @@ +syntax = "proto3"; + +// Package pluggableharness.slashcommand.v1 defines the slash-command +// provider plugin protocol described in specifications/slashcommand/. A +// direct-invoke slash command is a tool-shaped operation in its own +// right — this category exists so a plugin can declare and execute one +// without also being a tool provider and aliasing into one of its own +// tool operations. kind/risk/concurrency/ToolResult/ToolError/ +// OutputStream are pluggableharness.tool.v1 types reused verbatim here +// (identical gating and streaming semantics — see +// slashcommand/data-types.md), not redeclared. A prompt-expansion slash +// command (never executes anything, just expands a template) is not +// this category's concern — it stays declarable directly on any other +// category's own capability response as a PromptExpansionSpec (this +// package's types.proto). +package pluggableharness.slashcommand.v1; + +import "pluggableharness/slashcommand/v1/events.proto"; +import "pluggableharness/slashcommand/v1/rpc_request.proto"; +import "pluggableharness/slashcommand/v1/rpc_response.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1;slashcommandv1"; + +// SlashCommandService is the slash-command provider plugin protocol +// described in specifications/slashcommand/protocol.md. A slashcommand +// provider plugin exposes GetCapabilities, Configure, and Invoke; it MAY +// additionally implement Render and Preview. +service SlashCommandService { + // GetCapabilities returns the SlashCommandSpec for every command this + // plugin exposes, per slashcommand/protocol.md#getcapabilities. MUST be + // cheaply re-queryable and MUST NOT require a network call. + rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); + + // Configure decodes this provider's agent.hcl block, per + // slashcommand/protocol.md#configure. Errors surface as a gRPC status + // carrying a pluggableharness.tool.v1.ToolError in its structured + // detail, per grpc.md — not an in-band field on ConfigureResponse. + rpc Configure(ConfigureRequest) returns (ConfigureResponse); + + // Invoke executes one direct-invoke command and streams back its + // events, per slashcommand/protocol.md#invoke. Server-streaming, + // reusing tool/protocol.md#invoke's shape verbatim — a + // non-incremental command MUST still implement this shape, emitting + // exactly one terminal `result` or `error`. Cancellation is the kernel + // closing the gRPC stream; the plugin MUST treat this as normal + // control flow, never as an error. + rpc Invoke(InvokeRequest) returns (stream InvokeResponse); + + // Render returns a RenderTree for a previously-emitted opaque payload, + // per slashcommand/protocol.md#render. MAY be implemented; if absent, + // the kernel falls back to its generic default (pretty-printed JSON + // payload). + rpc Render(RenderRequest) returns (RenderResponse); + + // Preview returns a dry-run, human-readable description of what + // Invoke would do for the given call, without performing it — per + // slashcommand/protocol.md#preview. MAY be implemented; a kernel MUST + // tolerate its absence and fall back to showing the call's raw + // arguments in the plan/apply gate's permission UI. + rpc Preview(PreviewRequest) returns (PreviewResponse); + + // Describe reports this plugin build's own identity — per + // slashcommand/protocol.md#describe and configuration/lock-file.md's + // dev_overrides note, this is how the kernel learns a + // dev_overrides-resolved plugin's {name, version, source, category, + // protocol_version} when there is no lock-file entry to read it from. + rpc Describe(DescribeRequest) returns (DescribeResponse); +} diff --git a/api/pluggableharness/slashcommand/v1/slashcommand.proto b/api/pluggableharness/slashcommand/v1/slashcommand.proto deleted file mode 100644 index 18d2adf..0000000 --- a/api/pluggableharness/slashcommand/v1/slashcommand.proto +++ /dev/null @@ -1,51 +0,0 @@ -syntax = "proto3"; - -// Package pluggableharness.slashcommand.v1 defines the slash-command declaration -// shape described in specifications/configuration.md §5 (and equivalently -// specifications/frontend.md §5). Declarable as an optional repeated field -// in every category's capability response: model.md §2 Capabilities, -// tool.md §2 GetSchemaResponse, context.md §2 ContextCapabilities, -// memory.md §3 MemoryCapabilities. -package pluggableharness.slashcommand.v1; - -option go_package = "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1;slashcommandv1"; - -// Dispatch selects how a slash command reaches the kernel when invoked. -enum Dispatch { - // Zero value. Never valid for a real command; its presence on the wire - // means a caller forgot to set the field. - DISPATCH_UNSPECIFIED = 0; - // Maps the command's arguments directly to a tool call's input_schema - // and dispatches through the normal Invoke/plan-apply pipeline, - // including policy evaluation. Costs no model turn; the result is - // appended to history as an ordinary tool_result. - DISPATCH_DIRECT_INVOKE = 1; - // Expands SlashCommandSpec.template with the command's arguments and - // submits the result as an ordinary user_message. Costs a model turn. - DISPATCH_PROMPT_EXPANSION = 2; -} - -// SlashCommandSpec declares one slash command a plugin contributes. -message SlashCommandSpec { - // The command's name, without the leading "/". MUST be unique across - // every provider in the session — a name collision at config-load time - // is a hard error (configuration.md §5). - string name = 1; - - // Shown in the frontend's hotkey_hints region (frontend.md §2) and - // wherever else the frontend surfaces available commands. - string description = 2; - - // How this command is dispatched when invoked. - Dispatch dispatch = 3; - - // The tool operation to invoke. MUST be set if and only if - // dispatch == DISPATCH_DIRECT_INVOKE, and MUST name one of this same - // provider's own tool operations (tool.md §2.1) — a provider cannot - // declare a slash command that invokes another provider's tool. - optional string tool_name = 4; - - // The prompt template to expand, using "{arg}"-style placeholders. MUST - // be set if and only if dispatch == DISPATCH_PROMPT_EXPANSION. - optional string template = 5; -} diff --git a/api/pluggableharness/slashcommand/v1/types.proto b/api/pluggableharness/slashcommand/v1/types.proto new file mode 100644 index 0000000..dcfea28 --- /dev/null +++ b/api/pluggableharness/slashcommand/v1/types.proto @@ -0,0 +1,97 @@ +syntax = "proto3"; + +package pluggableharness.slashcommand.v1; + +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/schema/v1/types.proto"; +import "pluggableharness/tool/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1;slashcommandv1"; + +// Domain types for the slashcommand provider protocol. The +// prompt-expansion vocabulary every other category's capability +// response embeds directly lives in common.v1.PromptExpansionSpec +// instead, not here — it has no dependency on this package's own +// types (kind/risk/concurrency/schema), and homing it in common.v1 +// keeps that package's embedders from needing an edge into this one. + +// SlashCommandSpec declares one directly-invocable command this +// provider exposes — a tool-shaped operation in its own right, invoked +// via this same provider's own SlashCommandService.Invoke, never by +// naming another provider's tool operation. kind/risk/concurrency are +// pluggableharness.tool.v1 types, reused verbatim: a direct-invoke +// command flows through the identical plan/apply gate a tool call does +// (pluggableharness.plan.v1.PlanItem), so it needs the identical +// classification vocabulary, not a parallel copy of it. +message SlashCommandSpec { + // The command's name, without the leading "/". MUST be unique across + // every direct-invoke command declared by every provider in the + // session — a name collision at config-load time is a hard error + // (configuration.md §5). + string name = 1; + + // Shown in the frontend's hotkey_hints region and wherever else the + // frontend surfaces available commands. + string description = 2; + + // MUST — the common JSON-Schema subset per model.md §6, describing + // the shape of SlashCommandCall.arguments for this command. + pluggableharness.schema.v1.Schema input_schema = 3; + + // MUST — drives the plan/apply gate, identically to + // pluggableharness.tool.v1.ToolSchema.kind. + pluggableharness.tool.v1.ToolKind kind = 4; + + // MUST — see pluggableharness.tool.v1.RiskClass. + pluggableharness.tool.v1.RiskClass risk = 5; + + // MUST, except MUST NOT be meaningfully set for TOOL_KIND_INTERACTIVE. + pluggableharness.tool.v1.ConcurrencySpec concurrency = 6; + + // MUST — true if Invoke may emit intermediate SlashCommandEvents + // (output_chunk, progress, partial_result) before the terminal event; + // false if Invoke always emits exactly one terminal event with no + // lead-up. + bool streaming = 7; + + // SHOULD — the deadline the kernel applies to Invoke for this command + // absent an agent.hcl override. Absent means the kernel's global + // default applies instead (configuration/settings-and-global.md). + optional google.protobuf.Duration default_timeout = 8; + + // True iff re-running this command with identical arguments cannot + // produce a different end state than running it once. Gates whether + // the kernel MAY auto-retry a retryable + // pluggableharness.tool.v1.ToolError for a TOOL_KIND_RESOURCE command + // — see tool/conformance.md#error-taxonomy's retry interaction, reused + // verbatim. + bool idempotent = 9; + + // No output_schema: unlike a tool operation, a direct-invoke command + // is never presented to the model as a callable tool — it dispatches + // without a model turn — so there is no LLM-facing structured-output + // contract to validate its result against. +} + +// SlashCommandCall is one request to execute a direct-invoke command, +// structurally identical to pluggableharness.tool.v1.ToolCall. +message SlashCommandCall { + // MUST — kernel-assigned. Echoed in every SlashCommandEvent for this + // call, for correlation. + string id = 1; + + // MUST — matches a SlashCommandSpec.name from this provider's + // GetCapabilities response. + string name = 2; + + // MUST — already-parsed JSON conforming to that SlashCommandSpec's + // input_schema. + google.protobuf.Struct arguments = 3; + + // MUST be set by the kernel. Carries the session_id/turn_id this call + // executes for and the session's working_directory, identically to + // pluggableharness.tool.v1.ToolCall.call_context. + pluggableharness.common.v1.CallContext call_context = 4; +} diff --git a/api/pluggableharness/tool/v1/errors.proto b/api/pluggableharness/tool/v1/errors.proto new file mode 100644 index 0000000..c4ffd24 --- /dev/null +++ b/api/pluggableharness/tool/v1/errors.proto @@ -0,0 +1,74 @@ +syntax = "proto3"; + +package pluggableharness.tool.v1; + +import "google/protobuf/struct.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/tool/proto/v1;toolv1"; + +// The tool provider protocol's error taxonomy. + +// ToolErrorCategory classifies why an Invoke call failed, per tool.md §8. +// Deliberately distinct from model.md §8's ModelErrorCategory — there +// is no RATE_LIMITED or CONTEXT_LENGTH_EXCEEDED here, those are +// model-vendor concepts. The two enums MUST NOT be merged even though the +// surrounding error-envelope shape (category/message/retryable) is +// parallel. +enum ToolErrorCategory { + // Zero value. Never valid for a real error; its presence on the wire + // means a caller forgot to set the field. + TOOL_ERROR_CATEGORY_UNSPECIFIED = 0; + + // Input failed input_schema validation. + TOOL_ERROR_CATEGORY_INVALID_ARGUMENTS = 1; + + // The target of the operation doesn't exist (path, URL, symbol, ...). + TOOL_ERROR_CATEGORY_NOT_FOUND = 2; + + // OS/policy denied the underlying operation. + TOOL_ERROR_CATEGORY_PERMISSION_DENIED = 3; + + // The operation ran but failed on its own terms (non-zero exit, compiler + // error, HTTP 4xx/5xx) — not a plugin bug. + TOOL_ERROR_CATEGORY_EXECUTION_FAILED = 4; + + // Exceeded a plugin- or kernel-enforced deadline. + TOOL_ERROR_CATEGORY_TIMEOUT = 5; + + // The provider detected a conflicting concurrent call it could not + // serialize itself (see ConcurrencySpec) — signals the kernel to retry + // serialized. + TOOL_ERROR_CATEGORY_CONCURRENCY_CONFLICT = 6; + + // The stream was cancelled per tool.md §1 — not "an error" in the + // failure sense; kept distinct so the kernel doesn't surface it to the + // model as a tool failure when the whole turn is being aborted anyway. + TOOL_ERROR_CATEGORY_CANCELLED = 7; + + // The plugin subprocess itself died mid-Invoke (transport error, not a + // graceful error event the plugin chose to emit). MUST be + // kernel-synthesized only — a plugin process that crashes obviously + // cannot emit this itself; the kernel detects the crash and synthesizes + // this category. + TOOL_ERROR_CATEGORY_PROCESS_CRASHED = 8; + + // Anything else. MUST include the raw underlying error in + // ToolError.details. + TOOL_ERROR_CATEGORY_UNKNOWN = 9; +} + +// ToolError is the terminal, failed outcome of an Invoke call. +message ToolError { + // MUST. + ToolErrorCategory category = 1; + + // MUST — human-readable. + string message = 2; + + // MUST. + bool retryable = 3; + + // MAY — provider-specific structured detail. MUST include the raw + // underlying error for category TOOL_ERROR_CATEGORY_UNKNOWN. + optional google.protobuf.Struct details = 4; +} diff --git a/api/pluggableharness/tool/v1/events.proto b/api/pluggableharness/tool/v1/events.proto new file mode 100644 index 0000000..1ac0c93 --- /dev/null +++ b/api/pluggableharness/tool/v1/events.proto @@ -0,0 +1,101 @@ +syntax = "proto3"; + +package pluggableharness.tool.v1; + +import "google/protobuf/struct.proto"; +import "pluggableharness/tool/v1/errors.proto"; +import "pluggableharness/tool/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/tool/proto/v1;toolv1"; + +// Occurrence-shaped messages streamed by Invoke. + +// InvokeResponse wraps one message in the stream Invoke returns. A thin +// per-RPC envelope around ToolEvent, which keeps its own rich structure +// independent of the RPC signature. +message InvokeResponse { + // The event. + ToolEvent event = 1; +} + +// ToolEvent is one message in the stream Invoke returns, per tool.md §4. +// Exactly one of `result`/`error` closes the stream; `output_chunk`, +// `progress`, and `partial_result` MAY each appear zero or more times +// before it; `exit_status` MAY appear at most once. The relative order of +// `output_chunk` events MUST be preserved by the transport. +message ToolEvent { + oneof event { + // Incremental raw output from a process-backed operation. + OutputChunk output_chunk = 1; + + // A human-readable progress update. + Progress progress = 2; + + // Incremental structured output, e.g. search hits as they're found. + PartialResult partial_result = 3; + + // The exit status of a process-backed operation's child process. + ExitStatus exit_status = 4; + + // The terminal, successful result of this call. + ToolResult result = 5; + + // The terminal, failed result of this call. + ToolError error = 6; + } + + // OutputChunk carries one slice of raw stdout/stderr-shaped output from a + // process-backed operation. + message OutputChunk { + // Which stream this chunk came from. + OutputStream stream = 1; + + // The chunk's raw bytes. + bytes data = 2; + } + + // Progress carries a human-readable status update for a long-running + // call. + message Progress { + // A human-readable description of the current step. + string message = 1; + + // How far through the operation this call is, in [0.0, 1.0]. Absent + // means the provider cannot estimate completion fraction. + optional double fraction_complete = 2; + } + + // PartialResult carries incremental structured output before the + // terminal result, e.g. search hits as they're found. + message PartialResult { + // The incremental structured payload. + google.protobuf.Struct payload = 1; + } + + // ExitStatus carries a process-backed operation's child process exit + // information. exec-family tools only — a provider for a non-process- + // backed tool (file read, grep, web fetch) MUST NOT emit this. Appears + // at most once per Invoke stream. + message ExitStatus { + // The child process's exit code. + int32 exit_code = 1; + + // The signal that terminated the child process, if any. Absent means + // the process exited normally (exit_code is meaningful on its own). + optional string signal = 2; + } +} + +// OutputStream distinguishes which underlying stream an OutputChunk came +// from. +enum OutputStream { + // Zero value. Never valid for a real chunk; its presence on the wire + // means a caller forgot to set the field. + OUTPUT_STREAM_UNSPECIFIED = 0; + + // Standard output. + OUTPUT_STREAM_STDOUT = 1; + + // Standard error. + OUTPUT_STREAM_STDERR = 2; +} diff --git a/api/pluggableharness/tool/v1/rpc_request.proto b/api/pluggableharness/tool/v1/rpc_request.proto new file mode 100644 index 0000000..48ef094 --- /dev/null +++ b/api/pluggableharness/tool/v1/rpc_request.proto @@ -0,0 +1,52 @@ +syntax = "proto3"; + +package pluggableharness.tool.v1; + +import "google/protobuf/struct.proto"; +import "pluggableharness/tool/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/tool/proto/v1;toolv1"; + +// Request messages for the tool provider protocol's RPCs. + +// GetSchemaRequest carries no fields — GetSchema takes no parameters. +message GetSchemaRequest {} + +// ConfigureRequest wraps this provider's already-decoded agent.hcl config. +message ConfigureRequest { + // The provider-specific config, decoded from agent.hcl via the + // schema-to-cty bridge before crossing the wire. + google.protobuf.Struct config = 1; +} + +// InvokeRequest wraps the call to execute. A thin per-RPC envelope around +// ToolCall, which keeps its own rich structure independent of the RPC +// signature. +message InvokeRequest { + // The call to execute. + ToolCall call = 1; +} + +// RenderRequest carries the opaque payload to render, per tool.md §7. See +// grpc.md's Emit->Render->Paint carve-out for why this field stays `bytes` +// rather than a strongly-typed message. +message RenderRequest { + // The opaque emitted payload to render. + bytes payload = 1; + + // The schema version the payload was emitted under, per + // ../frontend/render-tree.md#schema-versioning. Lets a Render + // implementation decode a payload emitted by an older build of this + // same plugin. + string schema_version = 2; +} + +// PreviewRequest wraps the call to describe, per protocol.md#preview. +message PreviewRequest { + // The call Preview describes a dry run of. Same shape as an Invoke + // request; Preview MUST NOT execute it. + ToolCall call = 1; +} + +// DescribeRequest carries no fields — Describe takes no parameters. +message DescribeRequest {} diff --git a/api/pluggableharness/tool/v1/rpc_response.proto b/api/pluggableharness/tool/v1/rpc_response.proto new file mode 100644 index 0000000..9ff6519 --- /dev/null +++ b/api/pluggableharness/tool/v1/rpc_response.proto @@ -0,0 +1,78 @@ +syntax = "proto3"; + +package pluggableharness.tool.v1; + +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/config/v1/types.proto"; +import "pluggableharness/render/v1/types.proto"; +import "pluggableharness/tool/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/tool/proto/v1;toolv1"; + +// Unary response messages for the tool provider protocol's RPCs. + +// GetSchemaResponse is this provider's complete capability advertisement. +message GetSchemaResponse { + // One entry per operation this plugin exposes. + repeated ToolSchema tools = 1; + + // Prompt-expansion slash commands this provider contributes, per + // tool.md §2.1. MAY be empty. A direct-invoke command — one that + // actually executes something — is declared by a slashcommand.v1 + // provider instead (specifications/slashcommand/), never here; a + // tool provider wanting a direct-invoke shortcut into one of its own + // operations implements SlashCommandService alongside ToolService in + // the same process (go-plugin muxes multiple services per connection, + // per hook.v1's precedent). + repeated pluggableharness.common.v1.PromptExpansionSpec slash_commands = 2; + + // This provider's agent.hcl config schema, per configuration.md §4 — + // what fields Configure's request may be decoded from. + pluggableharness.config.v1.ConfigSchema config_schema = 3; + + // Which of the eight dispatchable hook points (hook/v1.HookPoint) this + // provider subscribes a HookSubscriberService to, per this provider's own + // agent.hcl hook{} blocks. Lets the kernel validate a hook{} declaration + // at config-load time instead of discovering an unsupported subscription + // only when that hook point first fires. + repeated pluggableharness.common.v1.HookPoint supported_hook_points = 4; +} + +// ConfigureResponse is empty on success. Errors surface as a gRPC status +// carrying a ToolError in its detail, per grpc.md — not an in-band field +// here. +message ConfigureResponse {} + +// RenderResponse wraps the rendered tree. A thin per-RPC envelope around +// RenderTree, which keeps its own rich structure independent of the RPC +// signature. +message RenderResponse { + // The rendered tree. + pluggableharness.render.v1.RenderTree tree = 1; +} + +// PreviewResponse carries a dry-run, human-readable description of what +// Invoke(call) would do, per protocol.md#preview — e.g. an edit tool +// returns the diff it would apply. Rendered into the plan/apply gate's +// permission UI via PlanItem.preview (pluggableharness.plan.v1, +// a sibling protocol revision) — that field and this response share the +// same pluggableharness.render.v1.RenderTree type by design, so a +// Preview call's output and a plan item's stored preview are +// interchangeable. +message PreviewResponse { + // The dry-run preview, rendered as a RenderTree. Producing this MUST NOT + // mutate anything and MUST be side-effect-free — the same guarantee + // TOOL_KIND_DATA_SOURCE operations make, but unconditionally, regardless + // of the call's actual ToolKind. + pluggableharness.render.v1.RenderTree preview = 1; +} + +// DescribeResponse reports this plugin build's own identity, per +// configuration/lock-file.md's dev_overrides note: a dev_overrides-resolved +// plugin has no lock-file entry for the kernel to read {name, version, +// source, category, protocol_version} from, so the kernel obtains it +// directly from the running process via this RPC instead. +message DescribeResponse { + // This plugin build's identity. + pluggableharness.common.v1.ProducerRef producer = 1; +} diff --git a/api/pluggableharness/tool/v1/service.proto b/api/pluggableharness/tool/v1/service.proto new file mode 100644 index 0000000..5d39182 --- /dev/null +++ b/api/pluggableharness/tool/v1/service.proto @@ -0,0 +1,58 @@ +syntax = "proto3"; + +// Package pluggableharness.tool.v1 defines the tool provider plugin protocol +// described in specifications/tool.md — the wire contract for file I/O, +// shell execution, search, web access, task tracking, sub-agent spawning, +// and similar operations. +package pluggableharness.tool.v1; + +import "pluggableharness/tool/v1/events.proto"; +import "pluggableharness/tool/v1/rpc_request.proto"; +import "pluggableharness/tool/v1/rpc_response.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/tool/proto/v1;toolv1"; + +// ToolService is the tool provider plugin protocol described in +// specifications/tool.md §1-§8. A tool provider plugin exposes GetSchema, +// Configure, and Invoke; it MAY additionally implement Render. +service ToolService { + // GetSchema returns the ToolSchema for every operation this plugin + // exposes, per tool.md §2. MUST be cheaply re-queryable and MUST NOT + // require a network call — a provider wrapping a hosted service declares + // its schema statically; only Invoke talks to the network. + rpc GetSchema(GetSchemaRequest) returns (GetSchemaResponse); + + // Configure decodes this provider's agent.hcl block, per tool.md §3. The + // request is already-decoded JSON (the schema-to-cty bridge is + // kernel-internal and never crosses the wire). Errors surface as a gRPC + // status carrying a ToolError in its structured detail, per grpc.md — not + // an in-band field on ConfigureResponse. + rpc Configure(ConfigureRequest) returns (ConfigureResponse); + + // Invoke executes one tool call and streams back its events, per + // tool.md §4. Server-streaming, reusing model.md §1's shape verbatim — + // a non-incremental operation MUST still implement this shape, emitting + // exactly one terminal `result` or `error`. Cancellation is the kernel + // closing the gRPC stream; the plugin MUST treat this as normal control + // flow (kill any child process, release handles), never as an error. + rpc Invoke(InvokeRequest) returns (stream InvokeResponse); + + // Render returns a RenderTree for a previously-emitted opaque payload, + // per tool.md §7. MAY be implemented; if absent, the kernel falls back to + // its generic default (pretty-printed JSON payload). + rpc Render(RenderRequest) returns (RenderResponse); + + // Preview returns a dry-run, human-readable description of what Invoke + // would do for the given call, without performing it — per + // protocol.md#preview. MAY be implemented; a kernel MUST tolerate its + // absence and fall back to showing the call's raw arguments in the + // plan/apply gate's permission UI. + rpc Preview(PreviewRequest) returns (PreviewResponse); + + // Describe reports this plugin build's own identity — per + // protocol.md#describe and configuration/lock-file.md's dev_overrides + // note, this is how the kernel learns a dev_overrides-resolved plugin's + // {name, version, source, category, protocol_version} when there is no + // lock-file entry to read it from. + rpc Describe(DescribeRequest) returns (DescribeResponse); +} diff --git a/api/pluggableharness/tool/v1/tool.proto b/api/pluggableharness/tool/v1/tool.proto deleted file mode 100644 index 5209f40..0000000 --- a/api/pluggableharness/tool/v1/tool.proto +++ /dev/null @@ -1,481 +0,0 @@ -syntax = "proto3"; - -// Package pluggableharness.tool.v1 defines the tool provider plugin protocol -// described in specifications/tool.md — the wire contract for file I/O, -// shell execution, search, web access, task tracking, sub-agent spawning, -// and similar operations. -package pluggableharness.tool.v1; - -import "google/protobuf/duration.proto"; -import "google/protobuf/struct.proto"; -import "pluggableharness/common/v1/common.proto"; -import "pluggableharness/config/v1/config.proto"; -import "pluggableharness/render/v1/render.proto"; -import "pluggableharness/schema/v1/schema.proto"; -import "pluggableharness/slashcommand/v1/slashcommand.proto"; - -option go_package = "github.com/pluggableharness/agent/pkg/tool/proto/v1;toolv1"; - -// ToolService is the tool provider plugin protocol described in -// specifications/tool.md §1-§8. A tool provider plugin exposes GetSchema, -// Configure, and Invoke; it MAY additionally implement Render. -service ToolService { - // GetSchema returns the ToolSchema for every operation this plugin - // exposes, per tool.md §2. MUST be cheaply re-queryable and MUST NOT - // require a network call — a provider wrapping a hosted service declares - // its schema statically; only Invoke talks to the network. - rpc GetSchema(GetSchemaRequest) returns (GetSchemaResponse); - - // Configure decodes this provider's agent.hcl block, per tool.md §3. The - // request is already-decoded JSON (the schema-to-cty bridge is - // kernel-internal and never crosses the wire). Errors surface as a gRPC - // status carrying a ToolError in its structured detail, per grpc.md — not - // an in-band field on ConfigureResponse. - rpc Configure(ConfigureRequest) returns (ConfigureResponse); - - // Invoke executes one tool call and streams back its events, per - // tool.md §4. Server-streaming, reusing model.md §1's shape verbatim — - // a non-incremental operation MUST still implement this shape, emitting - // exactly one terminal `result` or `error`. Cancellation is the kernel - // closing the gRPC stream; the plugin MUST treat this as normal control - // flow (kill any child process, release handles), never as an error. - rpc Invoke(InvokeRequest) returns (stream InvokeResponse); - - // Render returns a RenderTree for a previously-emitted opaque payload, - // per tool.md §7. MAY be implemented; if absent, the kernel falls back to - // its generic default (pretty-printed JSON payload). - rpc Render(RenderRequest) returns (RenderResponse); - - // Preview returns a dry-run, human-readable description of what Invoke - // would do for the given call, without performing it — per - // protocol.md#preview. MAY be implemented; a kernel MUST tolerate its - // absence and fall back to showing the call's raw arguments in the - // plan/apply gate's permission UI. - rpc Preview(PreviewRequest) returns (PreviewResponse); - - // Describe reports this plugin build's own identity — per - // protocol.md#describe and configuration/lock-file.md's dev_overrides - // note, this is how the kernel learns a dev_overrides-resolved plugin's - // {name, version, source, category, protocol_version} when there is no - // lock-file entry to read it from. - rpc Describe(DescribeRequest) returns (DescribeResponse); -} - -// GetSchemaRequest carries no fields — GetSchema takes no parameters. -message GetSchemaRequest {} - -// GetSchemaResponse is this provider's complete capability advertisement. -message GetSchemaResponse { - // One entry per operation this plugin exposes. - repeated ToolSchema tools = 1; - - // Slash commands this provider contributes, per tool.md §2.1. MAY be - // empty. Each entry's tool_name MUST reference one of this same - // provider's own operations declared in `tools` above — a provider - // cannot declare a slash command that invokes another provider's tool. - repeated pluggableharness.slashcommand.v1.SlashCommandSpec slash_commands = 2; - - // This provider's agent.hcl config schema, per configuration.md §4 — - // what fields Configure's request may be decoded from. - pluggableharness.config.v1.ConfigSchema config_schema = 3; - - // Which of the eight dispatchable hook points (hook/v1.HookPoint) this - // provider subscribes a HookSubscriberService to, per this provider's own - // agent.hcl hook{} blocks. Lets the kernel validate a hook{} declaration - // at config-load time instead of discovering an unsupported subscription - // only when that hook point first fires. - repeated pluggableharness.common.v1.HookPoint supported_hook_points = 4; -} - -// ConfigureRequest wraps this provider's already-decoded agent.hcl config. -message ConfigureRequest { - // The provider-specific config, decoded from agent.hcl via the - // schema-to-cty bridge before crossing the wire. - google.protobuf.Struct config = 1; -} - -// ConfigureResponse is empty on success. Errors surface as a gRPC status -// carrying a ToolError in its detail, per grpc.md — not an in-band field -// here. -message ConfigureResponse {} - -// ToolKind is the axis that drives the plan/apply gate, per tool.md §2. -// Deliberately separate from RiskClass: kind is the binary the gate -// mechanically needs, risk is a finer-grained classification within it. -enum ToolKind { - // Zero value. Never valid for a real operation; its presence on the wire - // means a caller forgot to set the field. - TOOL_KIND_UNSPECIFIED = 0; - - // Mutating. Gated behind the plan/apply approval gate. - TOOL_KIND_RESOURCE = 1; - - // Read-only. Executes freely, subject only to the policy precheck - // (agent-loop.md §5.4). - TOOL_KIND_DATA_SOURCE = 2; - - // Blocks the current turn for human input, per tool.md §2.1. Produces no - // state mutation of its own — the human's answer becomes the result. - TOOL_KIND_INTERACTIVE = 3; -} - -// RiskClass classifies an operation's blast radius, per tool.md §2. Orthogonal -// to ToolKind: kind determines whether the plan/apply gate applies at all, -// risk determines how significant the gated (or inherently ungated) action is. -enum RiskClass { - // Zero value. Never valid for a real operation; its presence on the wire - // means a caller forgot to set the field. - RISK_CLASS_UNSPECIFIED = 0; - - // Inherently unable to mutate anything the plugin controls. MUST be used - // for TOOL_KIND_DATA_SOURCE and TOOL_KIND_INTERACTIVE alike — neither - // mutates nor reads anything external, so neither has a blast radius to - // classify. - RISK_CLASS_READ_ONLY = 1; - - // A resource operation with narrow, easily-reversible blast radius, e.g. - // a write to a scratch path. - RISK_CLASS_LOW = 2; - - // A resource operation with real but bounded blast radius, e.g. editing - // a tracked source file. - RISK_CLASS_MODERATE = 3; - - // A resource operation with broad or hard-to-predict blast radius, e.g. - // arbitrary shell execution. - RISK_CLASS_HIGH = 4; - - // A resource operation capable of irreversible or wide-blast-radius - // action, e.g. `rm -rf`, a force-push, or spawning a sub-agent with - // further unattended write access. - // - // A TOOL_KIND_RESOURCE operation MUST declare one of LOW/MODERATE/HIGH/ - // CRITICAL — never READ_ONLY. There is no resource with read_only risk. - RISK_CLASS_CRITICAL = 5; -} - -// ConcurrencySpec declares whether this operation's Invoke calls may run -// concurrently against the same provider process, per tool.md §5. -message ConcurrencySpec { - // MUST be set, per operation, except for TOOL_KIND_INTERACTIVE. false (or - // an absent/unset default) means the kernel MUST NOT run any other Invoke - // call against this provider process concurrently with this one — a - // coarse, provider-wide lock. true means concurrent Invoke calls against - // this provider are generally safe. A provider that does not populate - // this field at all MUST be treated by the kernel as false — the - // conservative default. - bool safe = 1; - - // Only meaningful when safe == true. Names of this operation's - // input_schema fields whose value(s) form a serialization key. The - // kernel computes key = (provider_name, tool_name, value(key_fields)) and - // MUST serialize calls sharing an identical key, while still freely - // parallelizing calls with distinct keys. Omitting key_fields under - // safe == true asserts that no two calls to this operation can ever - // conflict — a strong claim, true for e.g. web_search, false for e.g. - // write_file. - repeated string key_fields = 2; - - // MUST NOT be declared for TOOL_KIND_INTERACTIVE — the kernel ignores it - // if present and always enforces strictly sequential execution for - // interactive calls, regardless of any declared value here (tool.md - // §2.1). -} - -// ToolSchema declares one operation this provider exposes, per tool.md §2. -message ToolSchema { - // MUST — unique within this provider's namespace, e.g. "read_file". - string name = 1; - - // MUST — drives the plan/apply gate. - ToolKind kind = 2; - - // MUST — see RiskClass. - RiskClass risk = 3; - - // MUST — shown to the model for tool selection and in plan diffs. - string description = 4; - - // MUST — the common JSON-Schema subset per model.md §6, describing the - // shape of ToolCall.arguments for this operation. - pluggableharness.schema.v1.Schema input_schema = 5; - - // MUST — the common JSON-Schema subset per model.md §6, describing the - // shape of ToolResult.payload for this operation. - pluggableharness.schema.v1.Schema output_schema = 6; - - // MUST — true if Invoke may emit intermediate ToolEvents (output_chunk, - // progress, partial_result) before the terminal event; false if Invoke - // always emits exactly one terminal event with no lead-up. - bool streaming = 7; - - // MUST, except MUST NOT be meaningfully set for TOOL_KIND_INTERACTIVE. - ConcurrencySpec concurrency = 8; - - // SHOULD — the deadline the kernel applies to Invoke for this operation - // absent an agent.hcl override. Absent means the kernel's global default - // applies instead (configuration/settings-and-global.md). - optional google.protobuf.Duration default_timeout = 9; - - // True iff re-running this operation with identical arguments cannot - // produce a different end state than running it once. Gates whether the - // kernel MAY auto-retry a retryable ToolError for a TOOL_KIND_RESOURCE - // operation — see conformance.md#error-taxonomy's retry interaction. - // TOOL_KIND_DATA_SOURCE operations are implicitly safe to retry - // regardless of this field. - bool idempotent = 10; -} - -// InvokeRequest wraps the call to execute. A thin per-RPC envelope around -// ToolCall, which keeps its own rich structure independent of the RPC -// signature. -message InvokeRequest { - // The call to execute. - ToolCall call = 1; -} - -// InvokeResponse wraps one message in the stream Invoke returns. A thin -// per-RPC envelope around ToolEvent, which keeps its own rich structure -// independent of the RPC signature. -message InvokeResponse { - // The event. - ToolEvent event = 1; -} - -// ToolCall is one request to execute an operation, per tool.md §4. -message ToolCall { - // MUST — kernel-assigned. Echoed in every ToolEvent for this call, for - // correlation. - string id = 1; - - // MUST — matches a ToolSchema.name from this provider's GetSchema - // response. - string tool_name = 2; - - // MUST — already-parsed JSON conforming to that ToolSchema's - // input_schema. - google.protobuf.Struct arguments = 3; - - // MUST be set by the kernel. Carries the session_id/turn_id this call - // executes for — what the plugin echoes back on its own - // KernelCallbackService.Emit/Log calls for attribution — and the - // session's working_directory, which any process-backed operation - // (the reference catalog's exec.bash, read_file, and similar) MUST - // resolve relative-path arguments against. See - // pluggableharness.common.v1.CallContext. - pluggableharness.common.v1.CallContext call_context = 4; -} - -// ToolEvent is one message in the stream Invoke returns, per tool.md §4. -// Exactly one of `result`/`error` closes the stream; `output_chunk`, -// `progress`, and `partial_result` MAY each appear zero or more times -// before it; `exit_status` MAY appear at most once. The relative order of -// `output_chunk` events MUST be preserved by the transport. -message ToolEvent { - oneof event { - // Incremental raw output from a process-backed operation. - OutputChunk output_chunk = 1; - - // A human-readable progress update. - Progress progress = 2; - - // Incremental structured output, e.g. search hits as they're found. - PartialResult partial_result = 3; - - // The exit status of a process-backed operation's child process. - ExitStatus exit_status = 4; - - // The terminal, successful result of this call. - ToolResult result = 5; - - // The terminal, failed result of this call. - ToolError error = 6; - } - - // OutputChunk carries one slice of raw stdout/stderr-shaped output from a - // process-backed operation. - message OutputChunk { - // Which stream this chunk came from. - OutputStream stream = 1; - - // The chunk's raw bytes. - bytes data = 2; - } - - // Progress carries a human-readable status update for a long-running - // call. - message Progress { - // A human-readable description of the current step. - string message = 1; - - // How far through the operation this call is, in [0.0, 1.0]. Absent - // means the provider cannot estimate completion fraction. - optional double fraction_complete = 2; - } - - // PartialResult carries incremental structured output before the - // terminal result, e.g. search hits as they're found. - message PartialResult { - // The incremental structured payload. - google.protobuf.Struct payload = 1; - } - - // ExitStatus carries a process-backed operation's child process exit - // information. exec-family tools only — a provider for a non-process- - // backed tool (file read, grep, web fetch) MUST NOT emit this. Appears - // at most once per Invoke stream. - message ExitStatus { - // The child process's exit code. - int32 exit_code = 1; - - // The signal that terminated the child process, if any. Absent means - // the process exited normally (exit_code is meaningful on its own). - optional string signal = 2; - } -} - -// OutputStream distinguishes which underlying stream an OutputChunk came -// from. -enum OutputStream { - // Zero value. Never valid for a real chunk; its presence on the wire - // means a caller forgot to set the field. - OUTPUT_STREAM_UNSPECIFIED = 0; - - // Standard output. - OUTPUT_STREAM_STDOUT = 1; - - // Standard error. - OUTPUT_STREAM_STDERR = 2; -} - -// ToolResult is the terminal, successful outcome of an Invoke call. -message ToolResult { - // MUST conform to the ToolSchema.output_schema declared for this call's - // tool_name; the kernel strictly validates this — a non-conforming - // payload becomes a ToolError with category TOOL_ERROR_CATEGORY_UNKNOWN - // rather than being passed through to history. - google.protobuf.Struct payload = 1; -} - -// ToolErrorCategory classifies why an Invoke call failed, per tool.md §8. -// Deliberately distinct from model.md §8's ModelErrorCategory — there -// is no RATE_LIMITED or CONTEXT_LENGTH_EXCEEDED here, those are -// model-vendor concepts. The two enums MUST NOT be merged even though the -// surrounding error-envelope shape (category/message/retryable) is -// parallel. -enum ToolErrorCategory { - // Zero value. Never valid for a real error; its presence on the wire - // means a caller forgot to set the field. - TOOL_ERROR_CATEGORY_UNSPECIFIED = 0; - - // Input failed input_schema validation. - TOOL_ERROR_CATEGORY_INVALID_ARGUMENTS = 1; - - // The target of the operation doesn't exist (path, URL, symbol, ...). - TOOL_ERROR_CATEGORY_NOT_FOUND = 2; - - // OS/policy denied the underlying operation. - TOOL_ERROR_CATEGORY_PERMISSION_DENIED = 3; - - // The operation ran but failed on its own terms (non-zero exit, compiler - // error, HTTP 4xx/5xx) — not a plugin bug. - TOOL_ERROR_CATEGORY_EXECUTION_FAILED = 4; - - // Exceeded a plugin- or kernel-enforced deadline. - TOOL_ERROR_CATEGORY_TIMEOUT = 5; - - // The provider detected a conflicting concurrent call it could not - // serialize itself (see ConcurrencySpec) — signals the kernel to retry - // serialized. - TOOL_ERROR_CATEGORY_CONCURRENCY_CONFLICT = 6; - - // The stream was cancelled per tool.md §1 — not "an error" in the - // failure sense; kept distinct so the kernel doesn't surface it to the - // model as a tool failure when the whole turn is being aborted anyway. - TOOL_ERROR_CATEGORY_CANCELLED = 7; - - // The plugin subprocess itself died mid-Invoke (transport error, not a - // graceful error event the plugin chose to emit). MUST be - // kernel-synthesized only — a plugin process that crashes obviously - // cannot emit this itself; the kernel detects the crash and synthesizes - // this category. - TOOL_ERROR_CATEGORY_PROCESS_CRASHED = 8; - - // Anything else. MUST include the raw underlying error in - // ToolError.details. - TOOL_ERROR_CATEGORY_UNKNOWN = 9; -} - -// ToolError is the terminal, failed outcome of an Invoke call. -message ToolError { - // MUST. - ToolErrorCategory category = 1; - - // MUST — human-readable. - string message = 2; - - // MUST. - bool retryable = 3; - - // MAY — provider-specific structured detail. MUST include the raw - // underlying error for category TOOL_ERROR_CATEGORY_UNKNOWN. - optional google.protobuf.Struct details = 4; -} - -// RenderRequest carries the opaque payload to render, per tool.md §7. See -// grpc.md's Emit->Render->Paint carve-out for why this field stays `bytes` -// rather than a strongly-typed message. -message RenderRequest { - // The opaque emitted payload to render. - bytes payload = 1; - - // The schema version the payload was emitted under, per - // ../frontend/render-tree.md#schema-versioning. Lets a Render - // implementation decode a payload emitted by an older build of this - // same plugin. - string schema_version = 2; -} - -// RenderResponse wraps the rendered tree. A thin per-RPC envelope around -// RenderTree, which keeps its own rich structure independent of the RPC -// signature. -message RenderResponse { - // The rendered tree. - pluggableharness.render.v1.RenderTree tree = 1; -} - -// PreviewRequest wraps the call to describe, per protocol.md#preview. -message PreviewRequest { - // The call Preview describes a dry run of. Same shape as an Invoke - // request; Preview MUST NOT execute it. - ToolCall call = 1; -} - -// PreviewResponse carries a dry-run, human-readable description of what -// Invoke(call) would do, per protocol.md#preview — e.g. an edit tool -// returns the diff it would apply. Rendered into the plan/apply gate's -// permission UI via PlanItem.preview (pluggableharness.plan.v1, -// a sibling protocol revision) — that field and this response share the -// same pluggableharness.render.v1.RenderTree type by design, so a -// Preview call's output and a plan item's stored preview are -// interchangeable. -message PreviewResponse { - // The dry-run preview, rendered as a RenderTree. Producing this MUST NOT - // mutate anything and MUST be side-effect-free — the same guarantee - // TOOL_KIND_DATA_SOURCE operations make, but unconditionally, regardless - // of the call's actual ToolKind. - pluggableharness.render.v1.RenderTree preview = 1; -} - -// DescribeRequest carries no fields — Describe takes no parameters. -message DescribeRequest {} - -// DescribeResponse reports this plugin build's own identity, per -// configuration/lock-file.md's dev_overrides note: a dev_overrides-resolved -// plugin has no lock-file entry for the kernel to read {name, version, -// source, category, protocol_version} from, so the kernel obtains it -// directly from the running process via this RPC instead. -message DescribeResponse { - // This plugin build's identity. - pluggableharness.common.v1.ProducerRef producer = 1; -} diff --git a/api/pluggableharness/tool/v1/types.proto b/api/pluggableharness/tool/v1/types.proto new file mode 100644 index 0000000..e18b6b2 --- /dev/null +++ b/api/pluggableharness/tool/v1/types.proto @@ -0,0 +1,172 @@ +syntax = "proto3"; + +package pluggableharness.tool.v1; + +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/schema/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/tool/proto/v1;toolv1"; + +// Domain types for the tool provider protocol. + +// ToolKind is the axis that drives the plan/apply gate, per tool.md §2. +// Deliberately separate from RiskClass: kind is the binary the gate +// mechanically needs, risk is a finer-grained classification within it. +enum ToolKind { + // Zero value. Never valid for a real operation; its presence on the wire + // means a caller forgot to set the field. + TOOL_KIND_UNSPECIFIED = 0; + + // Mutating. Gated behind the plan/apply approval gate. + TOOL_KIND_RESOURCE = 1; + + // Read-only. Executes freely, subject only to the policy precheck + // (agent-loop.md §5.4). + TOOL_KIND_DATA_SOURCE = 2; + + // Blocks the current turn for human input, per tool.md §2.1. Produces no + // state mutation of its own — the human's answer becomes the result. + TOOL_KIND_INTERACTIVE = 3; +} + +// RiskClass classifies an operation's blast radius, per tool.md §2. Orthogonal +// to ToolKind: kind determines whether the plan/apply gate applies at all, +// risk determines how significant the gated (or inherently ungated) action is. +enum RiskClass { + // Zero value. Never valid for a real operation; its presence on the wire + // means a caller forgot to set the field. + RISK_CLASS_UNSPECIFIED = 0; + + // Inherently unable to mutate anything the plugin controls. MUST be used + // for TOOL_KIND_DATA_SOURCE and TOOL_KIND_INTERACTIVE alike — neither + // mutates nor reads anything external, so neither has a blast radius to + // classify. + RISK_CLASS_READ_ONLY = 1; + + // A resource operation with narrow, easily-reversible blast radius, e.g. + // a write to a scratch path. + RISK_CLASS_LOW = 2; + + // A resource operation with real but bounded blast radius, e.g. editing + // a tracked source file. + RISK_CLASS_MODERATE = 3; + + // A resource operation with broad or hard-to-predict blast radius, e.g. + // arbitrary shell execution. + RISK_CLASS_HIGH = 4; + + // A resource operation capable of irreversible or wide-blast-radius + // action, e.g. `rm -rf`, a force-push, or spawning a sub-agent with + // further unattended write access. + // + // A TOOL_KIND_RESOURCE operation MUST declare one of LOW/MODERATE/HIGH/ + // CRITICAL — never READ_ONLY. There is no resource with read_only risk. + RISK_CLASS_CRITICAL = 5; +} + +// ConcurrencySpec declares whether this operation's Invoke calls may run +// concurrently against the same provider process, per tool.md §5. +message ConcurrencySpec { + // MUST be set, per operation, except for TOOL_KIND_INTERACTIVE. false (or + // an absent/unset default) means the kernel MUST NOT run any other Invoke + // call against this provider process concurrently with this one — a + // coarse, provider-wide lock. true means concurrent Invoke calls against + // this provider are generally safe. A provider that does not populate + // this field at all MUST be treated by the kernel as false — the + // conservative default. + bool safe = 1; + + // Only meaningful when safe == true. Names of this operation's + // input_schema fields whose value(s) form a serialization key. The + // kernel computes key = (provider_name, tool_name, value(key_fields)) and + // MUST serialize calls sharing an identical key, while still freely + // parallelizing calls with distinct keys. Omitting key_fields under + // safe == true asserts that no two calls to this operation can ever + // conflict — a strong claim, true for e.g. web_search, false for e.g. + // write_file. + repeated string key_fields = 2; + + // MUST NOT be declared for TOOL_KIND_INTERACTIVE — the kernel ignores it + // if present and always enforces strictly sequential execution for + // interactive calls, regardless of any declared value here (tool.md + // §2.1). +} + +// ToolSchema declares one operation this provider exposes, per tool.md §2. +message ToolSchema { + // MUST — unique within this provider's namespace, e.g. "read_file". + string name = 1; + + // MUST — drives the plan/apply gate. + ToolKind kind = 2; + + // MUST — see RiskClass. + RiskClass risk = 3; + + // MUST — shown to the model for tool selection and in plan diffs. + string description = 4; + + // MUST — the common JSON-Schema subset per model.md §6, describing the + // shape of ToolCall.arguments for this operation. + pluggableharness.schema.v1.Schema input_schema = 5; + + // MUST — the common JSON-Schema subset per model.md §6, describing the + // shape of ToolResult.payload for this operation. + pluggableharness.schema.v1.Schema output_schema = 6; + + // MUST — true if Invoke may emit intermediate ToolEvents (output_chunk, + // progress, partial_result) before the terminal event; false if Invoke + // always emits exactly one terminal event with no lead-up. + bool streaming = 7; + + // MUST, except MUST NOT be meaningfully set for TOOL_KIND_INTERACTIVE. + ConcurrencySpec concurrency = 8; + + // SHOULD — the deadline the kernel applies to Invoke for this operation + // absent an agent.hcl override. Absent means the kernel's global default + // applies instead (configuration/settings-and-global.md). + optional google.protobuf.Duration default_timeout = 9; + + // True iff re-running this operation with identical arguments cannot + // produce a different end state than running it once. Gates whether the + // kernel MAY auto-retry a retryable ToolError for a TOOL_KIND_RESOURCE + // operation — see conformance.md#error-taxonomy's retry interaction. + // TOOL_KIND_DATA_SOURCE operations are implicitly safe to retry + // regardless of this field. + bool idempotent = 10; +} + +// ToolCall is one request to execute an operation, per tool.md §4. +message ToolCall { + // MUST — kernel-assigned. Echoed in every ToolEvent for this call, for + // correlation. + string id = 1; + + // MUST — matches a ToolSchema.name from this provider's GetSchema + // response. + string tool_name = 2; + + // MUST — already-parsed JSON conforming to that ToolSchema's + // input_schema. + google.protobuf.Struct arguments = 3; + + // MUST be set by the kernel. Carries the session_id/turn_id this call + // executes for — what the plugin echoes back on its own + // KernelCallbackService.Emit/Log calls for attribution — and the + // session's working_directory, which any process-backed operation + // (the reference catalog's exec.bash, read_file, and similar) MUST + // resolve relative-path arguments against. See + // pluggableharness.common.v1.CallContext. + pluggableharness.common.v1.CallContext call_context = 4; +} + +// ToolResult is the terminal, successful outcome of an Invoke call. +message ToolResult { + // MUST conform to the ToolSchema.output_schema declared for this call's + // tool_name; the kernel strictly validates this — a non-conforming + // payload becomes a ToolError with category TOOL_ERROR_CATEGORY_UNKNOWN + // rather than being passed through to history. + google.protobuf.Struct payload = 1; +} diff --git a/api/pluggableharness/trace/v1/types.proto b/api/pluggableharness/trace/v1/types.proto new file mode 100644 index 0000000..89862d0 --- /dev/null +++ b/api/pluggableharness/trace/v1/types.proto @@ -0,0 +1,159 @@ +syntax = "proto3"; + +// Package pluggableharness.trace.v1 defines the wire shape of one relayed +// trace span, described in specifications/observability.md and consumed by +// pluggableharness.kernel.v1's ExportSpans RPC (KernelCallbackService). +// This is a minimal, purpose-built mirror of OTel's own span model — not a +// re-export of any upstream OTel proto package — so this project's wire +// contract stays self-contained and versioned under this series' own +// buf-breaking guarantees rather than an external schema's. A Span here is +// relayed, never re-created through the kernel's own tracer +// (observability.md#the-relay-model): the kernel forwards it to a +// collector essentially unchanged, so every identity/timing field below is +// authored by the plugin's own OTel SDK and MUST reach the kernel exactly +// as that SDK produced it. +package pluggableharness.trace.v1; + +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/trace/proto/v1;tracev1"; + +// SpanKind mirrors OTel's own span-kind taxonomy — which side of a call a +// span represents, when that's meaningful. +enum SpanKind { + // Zero value. Never valid on the wire; its presence means a caller + // forgot to set the field. + SPAN_KIND_UNSPECIFIED = 0; + // The default: an operation internal to the reporting process, with no + // remote counterpart. + SPAN_KIND_INTERNAL = 1; + // The receiving side of a synchronous remote call. + SPAN_KIND_SERVER = 2; + // The calling side of a synchronous remote call. + SPAN_KIND_CLIENT = 3; + // The initiating side of an asynchronous message. + SPAN_KIND_PRODUCER = 4; + // The receiving side of an asynchronous message. + SPAN_KIND_CONSUMER = 5; +} + +// StatusCode is a span's outcome, mirroring OTel's own three-value status +// model. +enum StatusCode { + // Zero value. Never valid on the wire; its presence means a caller + // forgot to set the field. + STATUS_CODE_UNSPECIFIED = 0; + // The operation completed successfully, or no status was explicitly set + // (OTel's own "Unset" reads as success for export purposes). + STATUS_CODE_OK = 1; + // The operation failed. + STATUS_CODE_ERROR = 2; +} + +// Status is a span's terminal outcome. +message Status { + // The outcome. MUST be set. + StatusCode code = 1; + + // A human-readable description of the status, meaningful only when + // code == STATUS_CODE_ERROR. MAY be empty. + string message = 2; +} + +// InstrumentationScope identifies the tracer that produced a Span, +// mirroring OTel's own instrumentation-scope concept (the tracer's own +// name/version, distinct from the plugin's ProducerRef, which the kernel +// attaches server-side at export time rather than reading from the span). +message InstrumentationScope { + // The tracer's name, e.g. "github.com/pluggableharness/agent/plugin". MUST be set. + string name = 1; + + // The tracer's version. MAY be empty. + string version = 2; +} + +// SpanEvent is a timestamped annotation attached to a Span, mirroring +// OTel's own span-event model (e.g. an exception recorded mid-span). +message SpanEvent { + // The event's name. MUST be set. + string name = 1; + + // When the event occurred. MUST be set. + google.protobuf.Timestamp time = 2; + + // The event's attributes. A Struct because the attribute set is + // genuinely open-ended per call site (see .claude/rules/proto.md's + // Struct carve-out). MAY be empty. + google.protobuf.Struct attributes = 3; +} + +// SpanLink references another span this one is causally related to +// without being its parent, mirroring OTel's own span-link model (e.g. a +// batched operation linking back to each request that fed it). +message SpanLink { + // The linked span's trace id: 32-character lowercase hex, the W3C + // trace-context trace-id format. MUST be set. + string trace_id = 1; + + // The linked span's span id: 16-character lowercase hex, the W3C + // trace-context span-id format. MUST be set. + string span_id = 2; + + // The link's attributes. A Struct for the same reason as SpanEvent's + // attributes above (.claude/rules/proto.md's Struct carve-out). MAY be + // empty. + google.protobuf.Struct attributes = 3; +} + +// Span is one completed trace span, exactly as the reporting plugin's own +// OTel SDK produced it. The kernel MUST NOT alter trace_id, span_id, +// parent_span_id, start_time, or end_time before relaying this to a +// collector (observability.md#span-relay-is-transparent) — doing so would +// silently sever this span from the parent/child relationships it already +// had within the plugin's own process. +message Span { + // This span's trace id: 32-character lowercase hex, the W3C + // trace-context trace-id format. MUST be set. + string trace_id = 1; + + // This span's own id: 16-character lowercase hex, the W3C trace-context + // span-id format. MUST be set. + string span_id = 2; + + // The parent span's id, same format as span_id. Absent for a root span. + optional string parent_span_id = 3; + + // The span's name. MUST be set. + string name = 4; + + // The span's kind. MUST be set. + SpanKind kind = 5; + + // When the span started. MUST be set. + google.protobuf.Timestamp start_time = 6; + + // When the span ended. MUST be set. + google.protobuf.Timestamp end_time = 7; + + // The span's terminal status. MUST be set. + Status status = 8; + + // The span's attributes. A Struct because the attribute set is + // genuinely open-ended per call site (see .claude/rules/proto.md's + // Struct carve-out) — unlike a metric.v1.MetricRecord's attributes, a + // span's attributes are never cardinality-bounded by the kernel + // (observability.md#the-tracing-metrics-asymmetry). MAY be empty. + google.protobuf.Struct attributes = 9; + + // Timestamped annotations recorded during the span's lifetime, in + // occurrence order. MAY be empty. + repeated SpanEvent events = 10; + + // Other spans this one is causally related to without being their + // parent. MAY be empty. + repeated SpanLink links = 11; + + // The tracer that produced this span. MUST be set. + InstrumentationScope scope = 12; +} diff --git a/api/pluggableharness/widget/v1/errors.proto b/api/pluggableharness/widget/v1/errors.proto new file mode 100644 index 0000000..c5f2e5e --- /dev/null +++ b/api/pluggableharness/widget/v1/errors.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +package pluggableharness.widget.v1; + +option go_package = "github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1"; + +// The widget provider protocol's error taxonomy. + +// WidgetErrorCategory classifies a WidgetError, mirroring +// FrontendErrorCategory's shape (frontend/v1/errors.proto) for the widget category — +// resolves frontend/conformance.md's prior open question of whether +// widgets need a structured error type of their own. +enum WidgetErrorCategory { + // Zero value. Never valid for a real error; its presence on the wire + // means a caller forgot to set the field. + WIDGET_ERROR_CATEGORY_UNSPECIFIED = 0; + // A RenderTree or WidgetUpdate could not be displayed. + WIDGET_ERROR_CATEGORY_RENDER_FAILED = 1; + // A WidgetUpdate named a Region this widget's frontend cannot honor. + WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED = 2; + // An error that does not fit any other category. + WIDGET_ERROR_CATEGORY_UNKNOWN = 3; +} + +// WidgetError is the structured error type for the widget category, +// mirroring FrontendError (frontend/v1/errors.proto). Unlike the frontend category +// (whose Attach errors surface in-band via ServerEvent.Error), widget +// Attach has no return channel other than the stream itself — WidgetError +// is carried in the structured detail of a gRPC status on Configure or +// Attach, per .claude/rules/grpc.md's error-taxonomy discipline, not as an +// in-band stream message. +message WidgetError { + // The error's category. + WidgetErrorCategory category = 1; + // A human-readable message. + string message = 2; +} diff --git a/api/pluggableharness/widget/v1/events.proto b/api/pluggableharness/widget/v1/events.proto new file mode 100644 index 0000000..95f6e62 --- /dev/null +++ b/api/pluggableharness/widget/v1/events.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package pluggableharness.widget.v1; + +import "pluggableharness/render/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1"; + +// Occurrence-shaped messages streamed by Attach. + +// WidgetUpdate is one pushed update to this widget's rendered content, per +// frontend.md §4.1. +message WidgetUpdate { + // Which region this update places content into. + pluggableharness.render.v1.Region region = 1; + + // The content to place. + pluggableharness.render.v1.RenderTree content = 2; + + // True: replace this widget's prior content in `region`. False: append. + bool replace = 3; +} diff --git a/api/pluggableharness/widget/v1/rpc_request.proto b/api/pluggableharness/widget/v1/rpc_request.proto new file mode 100644 index 0000000..a67be9d --- /dev/null +++ b/api/pluggableharness/widget/v1/rpc_request.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package pluggableharness.widget.v1; + +import "google/protobuf/struct.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1"; + +// Request messages for WidgetService's RPCs. + +// DescribeRequest carries no fields — Describe takes no parameters. +message DescribeRequest {} + +// GetCapabilitiesRequest carries no fields — GetCapabilities takes no +// parameters. +message GetCapabilitiesRequest {} + +// ConfigureRequest carries this provider's already-decoded agent.hcl block. +message ConfigureRequest { + // The provider's already-decoded config, per frontend.md §4.1. + google.protobuf.Struct config = 1; +} + +// AttachRequest identifies which session's widget instance to attach to. +message AttachRequest { + // The session this widget instance is attaching to. + string session_id = 1; +} diff --git a/api/pluggableharness/widget/v1/rpc_response.proto b/api/pluggableharness/widget/v1/rpc_response.proto new file mode 100644 index 0000000..342d0c8 --- /dev/null +++ b/api/pluggableharness/widget/v1/rpc_response.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package pluggableharness.widget.v1; + +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/widget/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1"; + +// Unary response messages for WidgetService's RPCs. + +// DescribeResponse reports this plugin build's own identity, obtained +// directly from the running process rather than a lock-file row — +// configuration/lock-file.md's "dev_overrides and identity without a lock +// entry". +message DescribeResponse { + pluggableharness.common.v1.ProducerRef producer = 1; +} + +// GetCapabilitiesResponse wraps WidgetCapabilities for the RPC signature, +// per this repo's per-RPC envelope convention (.claude/rules/proto.md). +message GetCapabilitiesResponse { + WidgetCapabilities capabilities = 1; +} + +// ConfigureResponse is empty on success. Errors surface as a gRPC status +// per grpc.md — not an in-band field here. +message ConfigureResponse {} diff --git a/api/pluggableharness/widget/v1/service.proto b/api/pluggableharness/widget/v1/service.proto new file mode 100644 index 0000000..c6a4460 --- /dev/null +++ b/api/pluggableharness/widget/v1/service.proto @@ -0,0 +1,60 @@ +syntax = "proto3"; + +// Package pluggableharness.widget.v1 defines the widget provider plugin protocol +// described in specifications/frontend.md §4 (Attach, action dispatch, ...). +// Messages and RPCs are added incrementally as the protocol is finalized; +// this file currently scaffolds the buf toolchain wiring — see +// .claude/rules/proto.md. +package pluggableharness.widget.v1; + +import "pluggableharness/widget/v1/events.proto"; +import "pluggableharness/widget/v1/rpc_request.proto"; +import "pluggableharness/widget/v1/rpc_response.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1"; + +// WidgetService is the widget provider plugin protocol described in +// specifications/frontend.md §4.1. A widget provider plugin exposes +// GetCapabilities, Configure, and Attach. Unlike the frontend provider's +// bidirectional Attach (frontend.md §3), this Attach is server-streaming +// only — widgets are passive/display-only in v1; a widget wanting to +// trigger an action does so by also being a tool provider with a slash +// command (frontend.md §5), not through this channel. +service WidgetService { + // GetCapabilities returns this widget's regions and config schema, per + // frontend.md §4.1. MUST be cheaply re-queryable and MUST NOT require a + // network call. + rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); + + // Configure decodes this provider's agent.hcl block, per frontend.md + // §4.1. The request is already-decoded JSON (the schema-to-cty bridge is + // kernel-internal and never crosses the wire). Errors surface as a gRPC + // status per grpc.md — not an in-band field on ConfigureResponse. + rpc Configure(ConfigureRequest) returns (ConfigureResponse); + + // Attach opens a server-streaming feed of this widget's rendered updates + // for one session, per frontend.md §4.1 — confirmed NOT bidi; widgets are + // passive/display-only in v1. A widget derives its displayed state via + // observe-mode hook subscription (agent-loop.md §4), not a separate + // session-state feed (frontend.md §4.2); this stream is purely how it + // pushes the resulting rendered updates out, it never receives anything + // back on this channel. Cancellation is the kernel closing the gRPC + // stream; the plugin MUST treat this as normal control flow, never as an + // error. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Stream element type is the bare "WidgetUpdate" per frontend.md §4.1's + // literal spec, naming the domain concept rather than the RPC. Not a + // uniqueness violation: WidgetUpdate is used by exactly this one RPC. + rpc Attach(AttachRequest) returns (stream WidgetUpdate); + + // Describe reports this plugin build's own identity — {name, version, + // source, category, protocol_version} — directly from the running + // process, rather than the kernel inferring it from a lock-file row. + // Every one of the seven category protocols gains this identical RPC in + // this protocol revision; it exists specifically for a + // `dev_overrides`-resolved binary (configuration/lock-file.md's + // "dev_overrides and identity without a lock entry"), which has no + // provider {} lock-file entry to read identity from at all. + rpc Describe(DescribeRequest) returns (DescribeResponse); +} diff --git a/api/pluggableharness/widget/v1/types.proto b/api/pluggableharness/widget/v1/types.proto new file mode 100644 index 0000000..46f10f2 --- /dev/null +++ b/api/pluggableharness/widget/v1/types.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package pluggableharness.widget.v1; + +import "pluggableharness/common/v1/types.proto"; +import "pluggableharness/config/v1/types.proto"; +import "pluggableharness/render/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1"; + +// Domain types for the widget provider protocol. + +// WidgetCapabilities is this widget provider's complete capability +// advertisement, per frontend.md §4.1. +message WidgetCapabilities { + // MUST — the regions this widget intends to contribute to. + repeated pluggableharness.render.v1.Region regions = 1; + + // This provider's agent.hcl config schema, per configuration.md §4 — + // what fields Configure's request may be decoded from. + pluggableharness.config.v1.ConfigSchema config_schema = 2; + + // Hook points this widget can subscribe to in observe mode + // (agent-loop/hook-dispatch.md), so a mis-declared agent.hcl hook{} + // block naming an unsupported point can be rejected at config-load + // time rather than failing at first dispatch. + repeated pluggableharness.common.v1.HookPoint supported_hook_points = 3; +} diff --git a/api/pluggableharness/widget/v1/widget.proto b/api/pluggableharness/widget/v1/widget.proto deleted file mode 100644 index bb30786..0000000 --- a/api/pluggableharness/widget/v1/widget.proto +++ /dev/null @@ -1,158 +0,0 @@ -syntax = "proto3"; - -// Package pluggableharness.widget.v1 defines the widget provider plugin protocol -// described in specifications/frontend.md §4 (Attach, action dispatch, ...). -// Messages and RPCs are added incrementally as the protocol is finalized; -// this file currently scaffolds the buf toolchain wiring — see -// .claude/rules/proto.md. -package pluggableharness.widget.v1; - -import "google/protobuf/struct.proto"; -import "pluggableharness/common/v1/common.proto"; -import "pluggableharness/config/v1/config.proto"; -import "pluggableharness/render/v1/render.proto"; - -option go_package = "github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1"; - -// WidgetService is the widget provider plugin protocol described in -// specifications/frontend.md §4.1. A widget provider plugin exposes -// GetCapabilities, Configure, and Attach. Unlike the frontend provider's -// bidirectional Attach (frontend.md §3), this Attach is server-streaming -// only — widgets are passive/display-only in v1; a widget wanting to -// trigger an action does so by also being a tool provider with a slash -// command (frontend.md §5), not through this channel. -service WidgetService { - // GetCapabilities returns this widget's regions and config schema, per - // frontend.md §4.1. MUST be cheaply re-queryable and MUST NOT require a - // network call. - rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); - - // Configure decodes this provider's agent.hcl block, per frontend.md - // §4.1. The request is already-decoded JSON (the schema-to-cty bridge is - // kernel-internal and never crosses the wire). Errors surface as a gRPC - // status per grpc.md — not an in-band field on ConfigureResponse. - rpc Configure(ConfigureRequest) returns (ConfigureResponse); - - // Attach opens a server-streaming feed of this widget's rendered updates - // for one session, per frontend.md §4.1 — confirmed NOT bidi; widgets are - // passive/display-only in v1. A widget derives its displayed state via - // observe-mode hook subscription (agent-loop.md §4), not a separate - // session-state feed (frontend.md §4.2); this stream is purely how it - // pushes the resulting rendered updates out, it never receives anything - // back on this channel. Cancellation is the kernel closing the gRPC - // stream; the plugin MUST treat this as normal control flow, never as an - // error. - // - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Stream element type is the bare "WidgetUpdate" per frontend.md §4.1's - // literal spec, naming the domain concept rather than the RPC. Not a - // uniqueness violation: WidgetUpdate is used by exactly this one RPC. - rpc Attach(AttachRequest) returns (stream WidgetUpdate); - - // Describe reports this plugin build's own identity — {name, version, - // source, category, protocol_version} — directly from the running - // process, rather than the kernel inferring it from a lock-file row. - // Every one of the six category protocols gains this identical RPC in - // this protocol revision; it exists specifically for a - // `dev_overrides`-resolved binary (configuration/lock-file.md's - // "dev_overrides and identity without a lock entry"), which has no - // provider {} lock-file entry to read identity from at all. - rpc Describe(DescribeRequest) returns (DescribeResponse); -} - -// DescribeRequest carries no fields — Describe takes no parameters. -message DescribeRequest {} - -// DescribeResponse reports this plugin build's own identity, obtained -// directly from the running process rather than a lock-file row — -// configuration/lock-file.md's "dev_overrides and identity without a lock -// entry". -message DescribeResponse { - pluggableharness.common.v1.ProducerRef producer = 1; -} - -// GetCapabilitiesRequest carries no fields — GetCapabilities takes no -// parameters. -message GetCapabilitiesRequest {} - -// GetCapabilitiesResponse wraps WidgetCapabilities for the RPC signature, -// per this repo's per-RPC envelope convention (.claude/rules/proto.md). -message GetCapabilitiesResponse { - WidgetCapabilities capabilities = 1; -} - -// WidgetCapabilities is this widget provider's complete capability -// advertisement, per frontend.md §4.1. -message WidgetCapabilities { - // MUST — the regions this widget intends to contribute to. - repeated pluggableharness.render.v1.Region regions = 1; - - // This provider's agent.hcl config schema, per configuration.md §4 — - // what fields Configure's request may be decoded from. - pluggableharness.config.v1.ConfigSchema config_schema = 2; - - // Hook points this widget can subscribe to in observe mode - // (agent-loop/hook-dispatch.md), so a mis-declared agent.hcl hook{} - // block naming an unsupported point can be rejected at config-load - // time rather than failing at first dispatch. - repeated pluggableharness.common.v1.HookPoint supported_hook_points = 3; -} - -// ConfigureRequest carries this provider's already-decoded agent.hcl block. -message ConfigureRequest { - // The provider's already-decoded config, per frontend.md §4.1. - google.protobuf.Struct config = 1; -} - -// ConfigureResponse is empty on success. Errors surface as a gRPC status -// per grpc.md — not an in-band field here. -message ConfigureResponse {} - -// AttachRequest identifies which session's widget instance to attach to. -message AttachRequest { - // The session this widget instance is attaching to. - string session_id = 1; -} - -// WidgetUpdate is one pushed update to this widget's rendered content, per -// frontend.md §4.1. -message WidgetUpdate { - // Which region this update places content into. - pluggableharness.render.v1.Region region = 1; - - // The content to place. - pluggableharness.render.v1.RenderTree content = 2; - - // True: replace this widget's prior content in `region`. False: append. - bool replace = 3; -} - -// WidgetErrorCategory classifies a WidgetError, mirroring -// FrontendErrorCategory's shape (frontend.proto) for the widget category — -// resolves frontend/conformance.md's prior open question of whether -// widgets need a structured error type of their own. -enum WidgetErrorCategory { - // Zero value. Never valid for a real error; its presence on the wire - // means a caller forgot to set the field. - WIDGET_ERROR_CATEGORY_UNSPECIFIED = 0; - // A RenderTree or WidgetUpdate could not be displayed. - WIDGET_ERROR_CATEGORY_RENDER_FAILED = 1; - // A WidgetUpdate named a Region this widget's frontend cannot honor. - WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED = 2; - // An error that does not fit any other category. - WIDGET_ERROR_CATEGORY_UNKNOWN = 3; -} - -// WidgetError is the structured error type for the widget category, -// mirroring FrontendError (frontend.proto). Unlike the frontend category -// (whose Attach errors surface in-band via ServerEvent.Error), widget -// Attach has no return channel other than the stream itself — WidgetError -// is carried in the structured detail of a gRPC status on Configure or -// Attach, per .claude/rules/grpc.md's error-taxonomy discipline, not as an -// in-band stream message. -message WidgetError { - // The error's category. - WidgetErrorCategory category = 1; - // A human-readable message. - string message = 2; -} diff --git a/docs/index.md b/docs/index.md index 4ec3f20..0f3962b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,7 +24,7 @@ The authoritative protocol contracts. RFC 2119 keywords are load-bearing; where

[Architecture](specifications/architecture.md) -Microkernel philosophy, the six plugin categories, Emit → Render → Paint, transport, registry, policy. +Microkernel philosophy, the seven plugin categories, Emit → Render → Paint, transport, registry, policy.
@@ -69,6 +69,12 @@ Cross-session persistence and recall, plus the record taxonomy. Frontend and widget plugin protocols and the shared render tree IR.
+
+[Slashcommand provider](specifications/slashcommand/README.md) + +Direct-invoke commands: a tool-shaped operation declared and executed in its own right. +
+
[Kernel callbacks](specifications/kernel-callbacks.md) diff --git a/docs/specifications/README.md b/docs/specifications/README.md index a7c0f4a..cc3ed78 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -2,23 +2,26 @@ The authoritative protocol and kernel-contract documentation for `PluggableHarness Agent`. This directory is the source of truth for anything it covers. -Start with [`conventions.md`](conventions.md) — it defines the requirement keywords and the anchor-only cross-reference rule every other file follows. Then [`glossary.md`](glossary.md) for terminology and [`architecture.md`](architecture.md) for the system-level narrative (microkernel philosophy, the six provider categories, Emit→Render→Paint, transport, config, registry, sub-agents, policy, hook dispatch). +Start with [`conventions.md`](conventions.md) — it defines the requirement keywords and the anchor-only cross-reference rule every other file follows. Then [`glossary.md`](glossary.md) for terminology and [`architecture.md`](architecture.md) for the system-level narrative (microkernel philosophy, the seven provider categories, Emit→Render→Paint, transport, config, registry, sub-agents, policy, hook dispatch). ## Reading order 1. [`conventions.md`](conventions.md) — how to read everything else. 2. [`glossary.md`](glossary.md) — terminology. 3. [`architecture.md`](architecture.md) — the system narrative. -4. The six plugin-category protocols (any order — cross-linked as needed): +4. The seven plugin-category protocols (any order — cross-linked as needed): - [`model/`](model/README.md) — model (LLM vendor) provider. - [`tool/`](tool/README.md) — tool provider (resource / data_source / interactive). - [`context/`](context/README.md) — context provider. - [`memory/`](memory/README.md) — memory provider. - [`frontend/`](frontend/README.md) — frontend provider **and** widget provider. + - [`slashcommand/`](slashcommand/README.md) — slash-command provider. 5. The kernel's own required behavior, not a plugin protocol: - [`agent-loop/`](agent-loop/README.md) — the turn loop, hook dispatch, plan/apply, sub-agents. - [`configuration/`](configuration/README.md) — `agent.hcl`, the policy DSL, agent profiles, global config, the lock file. - - [`kernel-callbacks.md`](kernel-callbacks.md) — the plugin→kernel direction (`RunSession`, `CountTokens`, `Emit`, `Log`). + - [`kernel-callbacks.md`](kernel-callbacks.md) — the plugin→kernel direction (`RunSession`, `CountTokens`, `Emit`, `Log`, `ExportSpans`, `RecordMetrics`, `GetTelemetryConfig`, `GetConfig`, `Publish`, `Subscribe`, `ReadEvents`, `GetSession`). + - [`event-bus.md`](event-bus.md) — the ephemeral, best-effort cross-plugin pub/sub primitive behind `Publish`/`Subscribe`. + - [`observability.md`](observability.md) — the tracing/metrics relay behind `ExportSpans`/`RecordMetrics`/`GetTelemetryConfig`. - [`state-backend.md`](state-backend.md) — session persistence and replay (sqlite, kernel-built-in, not pluggable in v1). ## What's not here diff --git a/docs/specifications/agent-loop/hook-dispatch.md b/docs/specifications/agent-loop/hook-dispatch.md index 1fc1b50..38ed3d6 100644 --- a/docs/specifications/agent-loop/hook-dispatch.md +++ b/docs/specifications/agent-loop/hook-dispatch.md @@ -4,13 +4,13 @@ ## Wire contract — `pluggableharness.hook.v1` -`pluggableharness.hook.v1.HookSubscriberService` (`api/pluggableharness/hook/v1/hook.proto`) is the wire surface every hook subscriber implements, regardless of which of the six plugin categories the subscribing plugin otherwise belongs to. It is one shared service, not a per-category RPC: `hashicorp/go-plugin` natively muxes multiple gRPC services over a single subprocess connection, so the kernel dials `HookSubscriberService` on the same connection it already holds to that plugin's category service. A plugin declaring no `hook{}` block in `agent.hcl` simply never has `DispatchHook` called. +`pluggableharness.hook.v1.HookSubscriberService` (`api/pluggableharness/hook/v1/service.proto`) is the wire surface every hook subscriber implements, regardless of which of the seven plugin categories the subscribing plugin otherwise belongs to. It is one shared service, not a per-category RPC: `hashicorp/go-plugin` natively muxes multiple gRPC services over a single subprocess connection, so the kernel dials `HookSubscriberService` on the same connection it already holds to that plugin's category service. A plugin declaring no `hook{}` block in `agent.hcl` simply never has `DispatchHook` called. -`DispatchHook` is unary — one hook-point firing, delivered to one subscriber, is one request/one response. There is no separate scheduling RPC or subscription-registration call; `agent.hcl`'s `hook{}` blocks are the sole source of "which plugins subscribe to which points in which mode," resolved at config-load time, and dispatch order within a point is the declaration order described in [Dispatch order and payload flow](#dispatch-order-and-payload-flow) below. +`DispatchHook` is unary — one hook-point firing, delivered to one subscriber, is one request/one response. There is no separate scheduling RPC or subscription-registration call; `agent.hcl`'s `hook{}` blocks are the sole source of "which plugins subscribe to which points in which mode," resolved at config-load time, and dispatch order within a point is the declaration order described in [Dispatch order and payload flow](#dispatch-order-and-payload-flow) below. This is a deliberately different subscription model from [`event-bus.md`](../event-bus.md)'s `Subscribe`: a bus subscription is runtime, not `agent.hcl`-declared, observe-only, and carries no ability to transform a payload or veto anything — the two mechanisms cover genuinely different needs (a static, ordered, potentially-blocking pipeline stage vs. an ad hoc, best-effort side channel) and neither is a substitute for the other. `agent.hcl`'s `hook{}` blocks remaining the sole source of hook subscription is unaffected by the event bus existing at all. ### Hook points -`common.v1.HookPoint` (homed in `common/v1/common.proto` — the import-nothing leaf — so category capability messages can advertise `supported_hook_points` without importing `hook.v1`, which itself imports several category packages for its typed payloads) enumerates eight of [`architecture.md`](../architecture.md#hook-dispatch-semantics)'s nine named points — every one except `context-assemble`, which stays on `ContextService.Contribute` ([`../context/protocol.md#contribute-the-context-assemble-rpc`](../context/protocol.md#contribute-the-context-assemble-rpc)) rather than riding this surface. `Contribute` already carries the full accumulated `ContextSection` chain as a first-class typed request/response; routing it through the generic `HookPayload` oneof below would just be a second, redundant path to the same effect with weaker typing. +`common.v1.HookPoint` (homed in `common/v1/types.proto` — the import-nothing leaf — so category capability messages can advertise `supported_hook_points` without importing `hook.v1`, which itself imports several category packages for its typed payloads) enumerates eight of [`architecture.md`](../architecture.md#hook-dispatch-semantics)'s nine named points — every one except `context-assemble`, which stays on `ContextService.Contribute` ([`../context/protocol.md#contribute-the-context-assemble-rpc`](../context/protocol.md#contribute-the-context-assemble-rpc)) rather than riding this surface. `Contribute` already carries the full accumulated `ContextSection` chain as a first-class typed request/response; routing it through the generic `HookPayload` oneof below would just be a second, redundant path to the same effect with weaker typing. | Hook point | `HookPayload` variant | |---|---| diff --git a/docs/specifications/agent-loop/plan-apply-gate.md b/docs/specifications/agent-loop/plan-apply-gate.md index 274f553..bde98da 100644 --- a/docs/specifications/agent-loop/plan-apply-gate.md +++ b/docs/specifications/agent-loop/plan-apply-gate.md @@ -4,20 +4,23 @@ The kernel's mechanism for approving mutating tool calls before they execute — ## Plan construction and policy evaluation -Resource calls identified at step 8 of [`turn-algorithm.md`](turn-algorithm.md#the-runturn-algorithm) are collected into a `Plan` and evaluated individually against policy — a policy object per tool call, not a mode flag, and the near-universal three-tier decision model (`allow`/`ask`/`deny`) present in essentially every surveyed open-source harness: +Resource calls identified at step 8 of [`turn-algorithm.md`](turn-algorithm.md#the-runturn-algorithm) are collected into a `Plan` and evaluated individually against policy — a policy object per gated call, not a mode flag, and the near-universal three-tier decision model (`allow`/`ask`/`deny`) present in essentially every surveyed open-source harness. Both a `tool.v1` provider (a `ToolCall`) and a `slashcommand.v1` provider (a `SlashCommandCall`) feed the same `Plan`; `PlanItem.producer_category` distinguishes which category produced a given item: ```protobuf PlanItem { - id, tool_call_id, provider, tool_name - input // parsed JSON args (kernel's canonical ToolCall representation, tool/data-types.md) - decision enum { pending, allow, ask, deny } - decided_by string // subscriber/policy-rule name that produced the decision + id, call_id, provider, operation_name + input // parsed JSON args (kernel's canonical ToolCall representation, tool/data-types.md) + decision enum { pending, allow, ask, deny } + decided_by string // subscriber/policy-rule name that produced the decision // Snapshot fields — see "Snapshot rationale" below. - kind tool.v1.ToolKind - risk tool.v1.RiskClass - description string - preview render.v1.RenderTree? // optional; see "Preview flow" below + kind tool.v1.ToolKind + risk tool.v1.RiskClass + description string + preview render.v1.RenderTree? // optional; see "Preview flow" below + + producer_category common.v1.Category // CATEGORY_TOOL or CATEGORY_SLASHCOMMAND — which + // producer built this item } Plan { @@ -26,15 +29,15 @@ Plan { } ``` -Policy evaluation happens as the `plan-ready` hook's `veto` chain ([`architecture.md`](../architecture.md#policy--first-party-not-a-plugin-category) — "policy is the kernel-privileged veto-mode subscriber at the plan-ready hook, always run, always respected"). The kernel MUST evaluate policy rules per `PlanItem`, not once for the whole plan — a plan with three resource calls against three different tool providers can and MUST receive three independently evaluated decisions. Presentation MAY batch multiple `ask`-decision items from the same plan into a single combined approval UI interaction (matching the "shown as a diff for approval" framing, and Terraform's own plan-diff precedent), but the decision unit underneath MUST remain per-item so a human can approve some resource calls in a plan and reject others without rejecting the whole turn — this also enables a corrected-input redirect (the model supplies corrected arguments rather than a binary accept/reject) as a frontend feature without a kernel data-model change; see [`frontend/frontend-protocol.md`](../frontend/frontend-protocol.md)'s `plan_decision.corrected_input`. +Policy evaluation happens as the `plan-ready` hook's `veto` chain ([`architecture.md`](../architecture.md#policy--first-party-not-a-plugin-category) — "policy is the kernel-privileged veto-mode subscriber at the plan-ready hook, always run, always respected"). The kernel MUST evaluate policy rules per `PlanItem`, not once for the whole plan — a plan with three resource calls against three different providers can and MUST receive three independently evaluated decisions. Presentation MAY batch multiple `ask`-decision items from the same plan into a single combined approval UI interaction (matching the "shown as a diff for approval" framing, and Terraform's own plan-diff precedent), but the decision unit underneath MUST remain per-item so a human can approve some resource calls in a plan and reject others without rejecting the whole turn — this also enables a corrected-input redirect (the model supplies corrected arguments rather than a binary accept/reject) as a frontend feature without a kernel data-model change; see [`frontend/frontend-protocol.md`](../frontend/frontend-protocol.md)'s `plan_decision.corrected_input`. ### Snapshot rationale -`kind`, `risk`, `description`, and `preview` are captured from the originating tool operation's `ToolSchema` (and, for `preview`, from a live `Preview` call — see below) at **plan-construction time**, not looked up live whenever a plan is later displayed or audited. This matters because `ToolSchema` itself is not immutable across a provider's lifetime: an operator can edit `agent.hcl`, a provider can ship a new version reclassifying an operation's risk, or a description can be reworded — none of which may retroactively alter what a *historical* plan's audit record says happened. `state-backend.md`'s `plan_items` table persists a `plan-ready`-time snapshot precisely so "what risk was this call classified at when it ran" stays answerable after the classification itself has since changed. A frontend rendering a live, in-progress plan and a CLI displaying a plan from six months ago both read the same snapshot fields — neither re-resolves against the provider's current `GetSchema` response. +`kind`, `risk`, `description`, and `preview` are captured from the originating operation's `ToolSchema` or `SlashCommandSpec` (per `producer_category`) — and, for `preview`, from a live `Preview` call — see below — at **plan-construction time**, not looked up live whenever a plan is later displayed or audited. This matters because neither schema type is immutable across a provider's lifetime: an operator can edit `agent.hcl`, a provider can ship a new version reclassifying an operation's risk, or a description can be reworded — none of which may retroactively alter what a *historical* plan's audit record says happened. `state-backend.md`'s `plan_items` table persists a `plan-ready`-time snapshot precisely so "what risk was this call classified at when it ran" stays answerable after the classification itself has since changed. A frontend rendering a live, in-progress plan and a CLI displaying a plan from six months ago both read the same snapshot fields — neither re-resolves against the provider's current capability advertisement (`GetSchema` for a `tool.v1` provider, `GetCapabilities` for a `slashcommand.v1` provider). ### Preview flow -`preview` is populated by the kernel calling the originating tool provider's `Preview` RPC ([`tool/protocol.md#preview`](../tool/protocol.md#preview)) at plan-construction time, for `TOOL_KIND_RESOURCE` items whose provider implements it — a dry-run description of the call's effect (e.g. a diff for a file write, a request summary for an HTTP call), returned as a `render.v1.RenderTree` and stored on the `PlanItem` verbatim, the same type `Preview`'s own RPC response carries. `data_source` and `interactive` items MUST NOT have `preview` populated — `Preview` is a resource-item concept, mirroring how only resource items reach the plan/apply gate's `allow`/`ask`/`deny` decision at all (see [Data source and interactive calls](#data-source-and-interactive-calls) below). +`preview` is populated by the kernel calling the originating provider's `Preview` RPC — [`tool/protocol.md#preview`](../tool/protocol.md#preview) for a `CATEGORY_TOOL` item, [`slashcommand/protocol.md#preview`](../slashcommand/protocol.md#preview) for a `CATEGORY_SLASHCOMMAND` item — at plan-construction time, for `TOOL_KIND_RESOURCE` items whose provider implements it — a dry-run description of the call's effect (e.g. a diff for a file write, a request summary for an HTTP call), returned as a `render.v1.RenderTree` and stored on the `PlanItem` verbatim, the same type either category's `Preview` RPC response carries. `data_source` and `interactive` items MUST NOT have `preview` populated — `Preview` is a resource-item concept, mirroring how only resource items reach the plan/apply gate's `allow`/`ask`/`deny` decision at all (see [Data source and interactive calls](#data-source-and-interactive-calls) below). A provider that does not implement `Preview` leaves `preview` absent on every `PlanItem` it produces — this is an ordinary, unexceptional absence, not an error condition; a frontend MUST fall back to rendering the raw `input` field (the call's parsed arguments) in that case, exactly as it would for a plan built before `Preview` existed. The kernel MUST NOT block plan construction on a slow or failing `Preview` call beyond its own ordinary per-RPC deadline (`.claude/rules/grpc.md`'s "Context and deadlines") — a `Preview` timeout or error degrades to an absent `preview` for that item, never to an aborted plan. @@ -52,7 +55,7 @@ An automated denial engine can otherwise fall into a denial-storm feedback loop, ## Data source and interactive calls -`data_source` calls are not policy-exempt, and neither are `interactive` calls. Before step 9/9b of [`turn-algorithm.md`](turn-algorithm.md#the-runturn-algorithm) executes them, each `data_source` and `interactive` call MUST be checked against policy — the same rule set the section above evaluates for resources — but with a narrower outcome space, since neither kind has an apply step to gate: `ask` is not a meaningful decision for either. +`data_source` calls are not policy-exempt, and neither are `interactive` calls. Before step 9/9b of [`turn-algorithm.md`](turn-algorithm.md#the-runturn-algorithm) executes them, each `data_source` and `interactive` call MUST be checked against policy — the same rule set the section above evaluates for resources — but with a narrower outcome space, since neither kind has an apply step to gate: `ask` is not a meaningful decision for either. This `kind`-based precheck applies identically regardless of `PlanItem.producer_category` — whether the originating `kind` came from a `tool.v1` `ToolSchema` or a `slashcommand.v1` `SlashCommandSpec`, `producer_category` only affects which provider's RPC the kernel calls, never which policy path a `data_source`/`interactive` item takes. - **`allow`** (including the case of no matching rule, the default for reads): the call proceeds unchanged — `data_source` calls to `execute_concurrently`, `interactive` calls to `execute_sequentially`. - **`deny`**: the call MUST NOT execute. The kernel MUST synthesize a `tool_result` denial block for it, identical in spirit to the resource-denial handling above, so the model observes the denial in its own history and can adapt on the next turn. @@ -71,7 +74,7 @@ The `interactive`-kind precheck reuses the `data_source` precheck's defaulting a [`frontend/frontend-protocol.md`](../frontend/frontend-protocol.md)'s `ClientEvent.PlanDecision` carries a `scope` field (`PlanDecisionScope`: `ONCE`/`SESSION`/`ALWAYS`) alongside `decision` and `corrected_input`. `scope` governs how durably the resolved decision applies beyond the one `PlanItem` it names — it is evaluated by the plan/apply gate at the same point `decision` and `corrected_input` are, immediately on receipt of a `plan_decision` client event, not deferred to any later stage: - **`ONCE`** (the default a frontend SHOULD send absent explicit operator intent): applies to the named `PlanItem` only. No durable record beyond the ordinary `plan_items` audit row this decision produces regardless of scope. -- **`SESSION`**: the kernel MUST remember this verdict for the rest of the current session, in memory — not written to `agent.hcl` or any persisted policy store — and apply it automatically to any future plan item in the same session matching the same `(provider, tool_name)` pair, without re-emitting a `permission_request`/blocking on a fresh `plan_decision`. A `SESSION`-scoped `deny` suppresses future `ask`/`allow` items the same way; a `SESSION`-scoped `allow` (with or without `corrected_input`) auto-applies the decision (re-validating `corrected_input` against the current call's own arguments each time, per [`frontend/frontend-protocol.md#plan_decisioncorrected_input`](../frontend/frontend-protocol.md#plan_decisioncorrected_input) — a `SESSION` scope remembers the *verdict*, not a frozen copy of the corrected arguments). This rule lapses at session end; it does not survive a `ResumeSession` re-open ([`frontend/frontend-protocol.md#resume-and-re-open-semantics`](../frontend/frontend-protocol.md#resume-and-re-open-semantics)) into a fresh session of rules. +- **`SESSION`**: the kernel MUST remember this verdict for the rest of the current session, in memory — not written to `agent.hcl` or any persisted policy store — and apply it automatically to any future plan item in the same session matching the same `(provider, operation_name)` pair, without re-emitting a `permission_request`/blocking on a fresh `plan_decision`. A `SESSION`-scoped `deny` suppresses future `ask`/`allow` items the same way; a `SESSION`-scoped `allow` (with or without `corrected_input`) auto-applies the decision (re-validating `corrected_input` against the current call's own arguments each time, per [`frontend/frontend-protocol.md#plan_decisioncorrected_input`](../frontend/frontend-protocol.md#plan_decisioncorrected_input) — a `SESSION` scope remembers the *verdict*, not a frozen copy of the corrected arguments). This rule lapses at session end; it does not survive a `ResumeSession` re-open ([`frontend/frontend-protocol.md#resume-and-re-open-semantics`](../frontend/frontend-protocol.md#resume-and-re-open-semantics)) into a fresh session of rules. - **`ALWAYS`**: the kernel MUST persist this verdict as policy — surviving beyond the current session, applying to future sessions under the same profile — via the same policy-rule mechanism [`configuration/policy-dsl.md`](../configuration/policy-dsl.md) already governs for operator-authored rules. This requires kernel-side policy persistence: a kernel build that cannot durably write a new policy rule (e.g. no writable policy store configured) MUST reject an `ALWAYS`-scoped `plan_decision` with a distinct error rather than silently downgrading it to `SESSION` or `ONCE` — a frontend and its operator need to know an "always allow this" request didn't actually stick, not discover it the next time the same prompt reappears. An `ALWAYS`-scoped decision, once persisted, takes effect starting with the *next* plan-ready evaluation it would match — it does not retroactively alter the plan item that triggered it, which has already been decided via the ordinary `decision`/`corrected_input` fields. `SESSION` and `ALWAYS` are both strictly broader than what the underlying per-item decision unit ([Plan construction and policy evaluation](#plan-construction-and-policy-evaluation) above) requires — they are a frontend/operator convenience layered on top of, not a replacement for, per-item evaluation: policy still runs and still produces an independent decision for every item in every future plan, it's simply that a `SESSION`/`ALWAYS` rule can now be one of the things that decision is based on. diff --git a/docs/specifications/architecture.md b/docs/specifications/architecture.md index 31189be..dd258d2 100644 --- a/docs/specifications/architecture.md +++ b/docs/specifications/architecture.md @@ -4,9 +4,9 @@ The kernel is a **microkernel**: it provides almost no functionality of its own, This is a deliberate fusion of two lineages: Terraform's plugin/provider/ schema/registry/plan-apply model, and a Neovim/VSCode-style hook-and- extension-point system for behavior injection — Terraform itself has no equivalent of the latter. See [`glossary.md`](glossary.md) for terminology. -## The six provider categories +## The seven provider categories -Six categories share a common shape: `GetSchema`/`GetCapabilities` (declare what you do), `Configure` (accept config decoded from HCL), then category-specific RPCs. +Seven categories share a common shape: `GetSchema`/`GetCapabilities` (declare what you do), `Configure` (accept config decoded from HCL), then category-specific RPCs. - **Model provider** ([`model/`](model/README.md)) — an LLM vendor. `GetCapabilities` returns a quantitative envelope per model (context window, thinking/caching modes, pricing tiers), not just feature flags. `StreamCompletion` is **server-streaming with cancellation**, not bidirectional — this matches how a real LLM vendor API actually works: one request in, one chunked/SSE response out; cancellation is the kernel closing the stream, a standard server-streaming operation. See [`model/README.md`](model/README.md#transport--lifecycle). - **Tool provider** ([`tool/`](tool/README.md)) — `GetSchema` returns resources/data-sources/interactive calls, each with a JSON-Schema input/ output and a `kind`. `Invoke` is server-streaming (so e.g. `exec` can stream live stdout instead of blocking). @@ -14,6 +14,7 @@ Six categories share a common shape: `GetSchema`/`GetCapabilities` (declare what - **Context provider** ([`context/`](context/README.md)) — hooks `context-assemble`, contributes text/data before each turn. Multiple can load simultaneously (a CLAUDE.md reader, an AGENTS.md reader, etc.) — sidesteps the convention-file format war entirely; it's a plugin choice, not a core opinion. - **Frontend provider** ([`frontend/`](frontend/README.md)) — one genuinely bidirectional stream: kernel emits state events (token deltas, plan-ready, permission-request, tool output), frontend emits client events (user message, plan approve/reject/edit, interrupt). Multiple frontends may attach to one session — see [`frontend/README.md`](frontend/README.md#session-scope--multi-attach). - **Widget provider** ([`frontend/widget-protocol.md`](frontend/widget-protocol.md)) — derives persistent display state from the same event stream a frontend already sees; no new data feed, server-streaming only. +- **Slash-command provider** ([`slashcommand/`](slashcommand/README.md)) — declares direct-invoke commands and executes them in its own right, a tool-shaped operation distinct from a tool provider's resource/data-source/interactive calls. `Invoke` is server-streaming, identical in shape to [`tool/`](tool/README.md)'s. ## Emit → Render → Paint pipeline diff --git a/docs/specifications/configuration/agent-profiles.md b/docs/specifications/configuration/agent-profiles.md index e48219d..9f51803 100644 --- a/docs/specifications/configuration/agent-profiles.md +++ b/docs/specifications/configuration/agent-profiles.md @@ -52,7 +52,7 @@ Model selection walks `primary` then `fallback` entries in declared order and re Recursion (a session spawning further sub-agents) needs no separate boolean flag: a profile simply cannot spawn children unless a spawn-capable tool (e.g. `agent.spawn_subagent`) is present in its own `tools` list. "Recursion disabled by default" falls out of the strict-default rule above, without inventing a redundant field. -**`slash_commands`** closes a gap the strict tool-scoping default doesn't cover: `direct_invoke` slash commands are naturally scoped by `tools` (a command naming an out-of-scope tool simply isn't registered), but `prompt_expansion` commands have no backing tool to scope against. `slash_commands` is a flat allow-list of command names (`["compact", "clear"]`); a profile omitting it entirely inherits **no** `prompt_expansion` commands — the same strict-default posture as `tools`, for the same reason. This has no effect on `direct_invoke` commands, which remain scoped by `tools` alone. See [`../frontend/README.md`](../frontend/README.md). +**`slash_commands`** closes a gap the strict tool-scoping default doesn't cover: a `direct_invoke` command is a genuinely tool-shaped, tool-kind-gated operation (see [`../agent-loop/plan-apply-gate.md#plan-construction-and-policy-evaluation`](../agent-loop/plan-apply-gate.md#plan-construction-and-policy-evaluation)) — its operation name folds into the same `tools` allow-list namespace tool operations already use, so a `direct_invoke` command is naturally scoped by `tools` alone (a command naming an out-of-scope tool simply isn't registered), the same as any `tool.v1` operation, just from a different producer category gated by the identical mechanism. `prompt_expansion` commands have no backing tool-shaped operation to scope against, which is what `slash_commands` exists for. `slash_commands` is a flat allow-list of command names (`["compact", "clear"]`); a profile omitting it entirely inherits **no** `prompt_expansion` commands — the same strict-default posture as `tools`, for the same reason. `slash_commands` scopes `prompt_expansion` commands (`common.v1.PromptExpansionSpec`) exclusively — that's its only remaining job, since `direct_invoke` has no backing "tool" to be exempt from anymore, it just joins the `tools` namespace. See [`../frontend/README.md`](../frontend/README.md). A profile's `tools` scoping is resolved against the set of providers actually loaded this session — each loaded provider's advertised tool names — into the concrete allowed set. diff --git a/docs/specifications/configuration/blocks-reference.md b/docs/specifications/configuration/blocks-reference.md index c02762e..f97653b 100644 --- a/docs/specifications/configuration/blocks-reference.md +++ b/docs/specifications/configuration/blocks-reference.md @@ -31,7 +31,7 @@ required_providers { - `source` MUST be a git-forge address (`github.com/...` or `gitlab.com/...`) — see [`architecture.md#registry--distribution`](../architecture.md#registry--distribution). - `version` MUST use the same constraint operators as Terraform: `=`, `!=`, `>`, `>=`, `<`, `<=`, `~>`. - The block's local name (`anthropic`, `filesystem` above) is what `provider` blocks and an `agent_profile`'s `model`/`tools` reference. It need not match the plugin's own advertised name. -- A provider's **category** (model/tool/context/memory/frontend/widget) is never declared here — the kernel discovers it after loading the plugin, from its own `GetCapabilities`/`GetSchema` response. +- A provider's **category** (model/tool/context/memory/frontend/widget/slashcommand) is never declared here — the kernel discovers it after loading the plugin, from its own `GetCapabilities`/`GetSchema` response. - v1 supports exactly **one instance per `required_providers` entry** — there is no Terraform-style `alias` mechanism for running the same plugin twice with different config (e.g. two `filesystem` roots with different permissions). This is a confirmed v1 limitation, not an oversight — see [`conformance.md#open-questions`](conformance.md#open-questions). Local names are whatever the operator chooses; each attribute's value MUST evaluate to an object or map carrying `source` and `version`. @@ -134,7 +134,7 @@ The decode and secret-resolution path is deliberately unlogged: because `env(... ```hcl settings { default_frontend = "tui" - log_level = "info" // trace | debug | info | warn | error + log_level = "info" // trace | debug | info | warn | error | fatal telemetry = false retry { @@ -154,10 +154,14 @@ settings { service_name = "pluggableharness-agent" resource_attrs = { env = "prod" } } + + event_bus { + subscribe_queue_bound = 1024 + } } ``` -`settings{}` is the home for cross-cutting, non-provider-specific, non-policy-shaped options — `default_frontend` names which `required_providers` entry the CLI attaches when more than one frontend provider is loaded, and `log_level` is exactly what it says. This block is intentionally small: a field that's really provider-specific belongs in that provider's own `provider{}` block, and a field that's really about approval/blocking behavior belongs in `policy{}`. `retry{}`'s canonical backoff defaults and `telemetry`'s master switch are covered in [`settings-and-global.md`](settings-and-global.md); the rest of this section covers `observability{}`. +`settings{}` is the home for cross-cutting, non-provider-specific, non-policy-shaped options — `default_frontend` names which `required_providers` entry the CLI attaches when more than one frontend provider is loaded, and `log_level` is exactly what it says. `log_level`'s domain is the full six-level wire vocabulary (`kernel-callbacks.md#log`'s `trace | debug | info | warn | error | fatal`), not a five-level subset — a config that could never even select `FATAL` would be unable to exercise the one severity `kernel-callbacks.md#log` singles out as a label-only value. This block is intentionally small: a field that's really provider-specific belongs in that provider's own `provider{}` block, and a field that's really about approval/blocking behavior belongs in `policy{}`. `retry{}`'s canonical backoff defaults and `telemetry`'s master switch are covered in [`settings-and-global.md`](settings-and-global.md); the rest of this section covers `observability{}` and `event_bus{}`. ### `observability{}` @@ -178,3 +182,11 @@ The current, full shape of the OTel-specific sub-block that controls the kernel' `resource_attrs` is the **one** optional field in the sub-block — every other field is `Required: true`, matching this block's existing all-or- nothing convention for `retry{}` (see [`settings-and-global.md#retry-defaults`](settings-and-global.md#retry-defaults)). When `telemetry = false`, the kernel MUST wire a discarding backend regardless of `observability{}`'s contents — no exporter is ever constructed. `traces_enabled`/`metrics_enabled`/`logs_enabled` let an operator run with, say, metrics and logs on but tracing off. `retry{}` and `observability{}` each receive their canonical defaults even when the enclosing `settings{}` block is entirely absent from `agent.hcl`, not only when `settings{}` is present but a specific sub-block is omitted — a config with no `settings{}` block at all still ends up with fully defaulted retry and observability behavior. + +### `event_bus{}` + +| Field | Type | Required | Meaning | +|---|---|---|---| +| `subscribe_queue_bound` | number | **no**, default `1024` | The per-`Subscribe`-stream undelivered-event bound described in [`event-bus.md#backpressure`](../event-bus.md#backpressure). Once a stream's queue exceeds this, the kernel closes it with `codes.ResourceExhausted` rather than growing it further. | + +`event_bus{}` receives its canonical default the same way `retry{}` and `observability{}` do — a config with no `settings{}` block at all, or one that omits `event_bus{}` entirely, still runs with `subscribe_queue_bound = 1024`. Unlike `observability{}`, this sub-block has no all-or-nothing convention to preserve, since it declares exactly one field. diff --git a/docs/specifications/configuration/lock-file.md b/docs/specifications/configuration/lock-file.md index ffcc178..fffa8c6 100644 --- a/docs/specifications/configuration/lock-file.md +++ b/docs/specifications/configuration/lock-file.md @@ -46,4 +46,4 @@ Checksum verification logs only the binary path and platform, at `DEBUG` level. ## `dev_overrides` and identity without a lock entry -A binary resolved via [`settings-and-global.md#dev_overrides`](settings-and-global.md#dev_overrides) has no `provider "" { ... }` entry in this file at all — `dev_overrides` exists precisely to bypass the registry/lock-file resolution path, so there is no `source`/`version`/`checksums` for the kernel to read identity from the way it would for a normally-resolved plugin. The kernel instead obtains that plugin build's identity directly from the process itself, via that category's own `Describe` RPC — a `Describe(DescribeRequest) -> DescribeResponse { producer: common.v1.ProducerRef }` call every one of the six category protocols gains in this same protocol revision. The plugin reports its own `{name, version, source, category, protocol_version}` at connection time, rather than the kernel inferring it from a lock-file row that in this case doesn't exist. This is the canonical explanation for the general "how does the kernel know what it's actually running" question wherever a `dev_overrides` binary is in play; other specs needing to address plugin identity resolution without a lock entry should point here rather than re-deriving it. +A binary resolved via [`settings-and-global.md#dev_overrides`](settings-and-global.md#dev_overrides) has no `provider "" { ... }` entry in this file at all — `dev_overrides` exists precisely to bypass the registry/lock-file resolution path, so there is no `source`/`version`/`checksums` for the kernel to read identity from the way it would for a normally-resolved plugin. The kernel instead obtains that plugin build's identity directly from the process itself, via that category's own `Describe` RPC — a `Describe(DescribeRequest) -> DescribeResponse { producer: common.v1.ProducerRef }` call every one of the seven category protocols gains in this same protocol revision. The plugin reports its own `{name, version, source, category, protocol_version}` at connection time, rather than the kernel inferring it from a lock-file row that in this case doesn't exist. This is the canonical explanation for the general "how does the kernel know what it's actually running" question wherever a `dev_overrides` binary is in play; other specs needing to address plugin identity resolution without a lock entry should point here rather than re-deriving it. diff --git a/docs/specifications/context/README.md b/docs/specifications/context/README.md index cef656f..c52011f 100644 --- a/docs/specifications/context/README.md +++ b/docs/specifications/context/README.md @@ -1,6 +1,6 @@ # Context provider protocol -Covers the **context provider** category — a plugin that hooks `context-assemble` and contributes text content to the prompt before each model call (e.g. a CLAUDE.md reader, an AGENTS.md reader, a git-status/ file-tree summarizer). Multiple context providers may be configured and load simultaneously in the same `agent.hcl` — this sidesteps the convention-file format war entirely; which file(s) a session reads is a plugin/config choice, not a kernel opinion. See [`architecture.md`](../architecture.md#the-six-provider-categories) for where this category sits among the other five. +Covers the **context provider** category — a plugin that hooks `context-assemble` and contributes text content to the prompt before each model call (e.g. a CLAUDE.md reader, an AGENTS.md reader, a git-status/ file-tree summarizer). Multiple context providers may be configured and load simultaneously in the same `agent.hcl` — this sidesteps the convention-file format war entirely; which file(s) a session reads is a plugin/config choice, not a kernel opinion. See [`architecture.md`](../architecture.md#the-seven-provider-categories) for where this category sits among the other six. ## Scope boundary @@ -12,7 +12,7 @@ This category covers content injected into the prompt *before* a model call, sou ## Transport & lifecycle -Subprocess + gRPC via `hashicorp/go-plugin`, per [`architecture.md`](../architecture.md#transport). The standard handshake applies uniformly across all six provider categories and isn't repeated per category. +Subprocess + gRPC via `hashicorp/go-plugin`, per [`architecture.md`](../architecture.md#transport). The standard handshake applies uniformly across all seven provider categories and isn't repeated per category. A context provider plugin exposes four RPCs: `GetCapabilities`, `Configure`, `Contribute`, `Describe`. It MAY additionally implement `Render` (see [`protocol.md#render`](protocol.md#render)). diff --git a/docs/specifications/context/protocol.md b/docs/specifications/context/protocol.md index 70aab57..ee8c4f6 100644 --- a/docs/specifications/context/protocol.md +++ b/docs/specifications/context/protocol.md @@ -20,7 +20,7 @@ ContextCapabilities { `compactor` is a normal capability flag, not a special case: a context provider MAY declare itself the one responsible for compacting or summarizing content — including, but not limited to, other providers' already-assembled sections and the session's conversation history — when the context budget is under pressure. Declaring `compactor: true` extends what this provider is allowed to touch on `Contribute` (see [`data-types.md#ordering--chaining`](data-types.md#ordering--chaining)); a provider that doesn't declare it stays confined to its own section(s). -`ContextCapabilities` MAY additionally include `slash_commands: []SlashCommandSpec` and MUST include the provider's `ConfigSchema`, so the kernel knows what fields `Configure` expects before ever calling it — the same shape every provider category's `GetCapabilities` follows, see [`model/protocol.md#getcapabilities`](../model/protocol.md#getcapabilities). +`ContextCapabilities` MAY additionally include `slash_commands: []common.v1.PromptExpansionSpec` and MUST include the provider's `ConfigSchema`, so the kernel knows what fields `Configure` expects before ever calling it — the same shape every provider category's `GetCapabilities` follows, see [`model/protocol.md#getcapabilities`](../model/protocol.md#getcapabilities). Each `PromptExpansionSpec` is a static template-expansion command only, with no way to invoke anything — the kernel expands `template` with the user's arguments and submits the result as an ordinary user message. A direct-invoke command is declared by a `slashcommand.v1` provider instead ([`../slashcommand/protocol.md`](../slashcommand/protocol.md)), never here. `ContextCapabilities` also MAY declare `supported_hook_points: []common.v1.HookPoint` — which hook points (beyond `context-assemble` itself, which never rides this field) this provider subscribes `HookSubscriberService.DispatchHook` to, per [`agent-loop/hook-dispatch.md`](../agent-loop/hook-dispatch.md). Empty unless this provider's `agent.hcl` also declares `hook{}` blocks. diff --git a/docs/specifications/conventions.md b/docs/specifications/conventions.md index ef4e3b1..94c1840 100644 --- a/docs/specifications/conventions.md +++ b/docs/specifications/conventions.md @@ -31,7 +31,7 @@ When linking to a heading, use GitHub-flavored anchor rules: lowercase, spaces t ## Directory shape -Each plugin-category directory (`model/`, `tool/`, `context/`, `memory/`, `frontend/`) follows the same five-file template: +Each plugin-category directory (`model/`, `tool/`, `context/`, `memory/`, `frontend/`, `slashcommand/`) follows the same five-file template: - `README.md` — overview and transport & lifecycle. - `protocol.md` — every RPC in the category, request/response shape, MUST/SHOULD/MAY behavior. diff --git a/docs/specifications/event-bus.md b/docs/specifications/event-bus.md new file mode 100644 index 0000000..8e5ef43 --- /dev/null +++ b/docs/specifications/event-bus.md @@ -0,0 +1,67 @@ +# Event bus + +This is the third kernel-owned (non-plugin) spec, alongside [`kernel-callbacks.md`](kernel-callbacks.md) and [`state-backend.md`](state-backend.md). It defines the ephemeral, best-effort, cross-plugin publish/subscribe primitive exposed by `KernelCallbackService.Publish`/`.Subscribe` ([`kernel-callbacks.md#publish`](kernel-callbacks.md#publish), [`kernel-callbacks.md#subscribe`](kernel-callbacks.md#subscribe)) — the wire shape lives there; this document covers topic grammar, delivery semantics, and the boundary against three other things that already use "event" vocabulary in this project. + +## Why a fourth "event" concept, and why it's not one of the other three + +`docs/specifications/` already uses "emit," "subscribe," and "broadcast" for three unrelated things, and this bus is deliberately a fourth, distinct from all of them: + +| Mechanism | Durability | Ordering | Delivery | Who decides subscription | +|---|---|---|---|---| +| **`Emit`** ([`kernel-callbacks.md#emit`](kernel-callbacks.md#emit)) | Durable — persisted to sqlite, survives a restart | `sequence`, ordering-authoritative | Write-only from the plugin's side; read back via `ReadEvents` (pull) | N/A — there is no subscription, only a per-session log | +| **Hook dispatch** ([`agent-loop/hook-dispatch.md`](agent-loop/hook-dispatch.md)) | Not persisted as its own record (though `EVENT_KIND_HOOK_ERROR` can result) | Strictly sequential per hook point; can veto and short-circuit | Synchronous, unary `DispatchHook` per subscriber, in declared order | `agent.hcl`'s `hook{}` blocks, resolved at config-load time — static, not runtime | +| **Frontend broadcast** (`frontend/frontend-protocol.md`) | Not persisted (the persisted record is the underlying `Emit`, if any) | Delivery order only, no ordering guarantee across frontends | Push over the `Attach` bidi stream to every attached frontend | Attaching to a session, a control-plane operation | +| **Event bus** (this document) | **Not persisted at all** — vaporizes on process exit or when a subscription closes | Per-subscriber FIFO only; no cross-subscriber or global ordering | Push over `Subscribe`'s server stream, best-effort | Whichever plugin calls `Subscribe`, at runtime, with no `agent.hcl` declaration | + +The bus exists for a case none of the other three covers: two plugins that want to react to each other's activity in near-real-time, without either being declared as a hook subscriber in `agent.hcl` (hook dispatch's static, ordered, potentially-vetoing model is the wrong shape for this — a broken or slow bus subscriber MUST NOT be able to block or veto anything), and without needing the durability or `ReadEvents` polling overhead `Emit` provides for state that must survive a restart. + +**A hook point firing does not imply a bus event, and a bus event does not imply anything was persisted.** These three mechanisms deliberately don't share a code path, mirroring [`state-backend.md`](state-backend.md)'s own observation that a hook point firing is not the same thing as an event being persisted. + +## Implementation note: this bus already exists, in-process + +The kernel-internal primitive backing `Publish`/`Subscribe` is `internal/eventbus` — an existing, already-tested, telemetry-aware in-process pub/sub `Bus` (topic string, handler callback, per-subscriber unbounded FIFO queue, never-blocking, never-dropping). Everything in this document about topic grammar, the reserved `kernel.*` namespace, and delivery semantics governs the plugin-facing RPC surface built on top of that primitive — the in-process `Bus` itself is unopinionated about topic naming and unaware that "plugin" or "kernel" mean anything in particular. Where this document's guarantees differ from `internal/eventbus`'s own (see "Backpressure" below), the difference is entirely in the RPC bridge layered on top, not a change to the underlying `Bus`. + +## Topic grammar + +A topic is a dot-separated string. Two namespaces exist, and a plugin can only ever construct a topic in one of them: + +- **`plugin.{category}.{name}.{event_type}`** — every event a plugin publishes lands here. `category` and `name` come from the publishing plugin's own producer identity (`common.v1.Category`'s lowercase text, `state-backend.md`'s own vocabulary, and the plugin's declared name), server-derived from the authenticated callback connection exactly as `Emit`'s and `Log`'s producer attribution is — never a value the plugin supplies. `event_type` is the one segment a `Publish` caller does supply (`kernel-callbacks.md#publish`'s `PublishRequest.event_type`), constrained to a single dot-free, wildcard-free segment. `category` is part of the topic, not just `name`, because two different categories can each ship a plugin with the same declared name (a `github` tool provider and a `github` context provider are different producers and MUST land on different topics). +- **`kernel.*`** — reserved for the kernel itself. A plugin MAY subscribe to any `kernel.*` topic but MUST NOT be able to publish onto one — enforced by construction, not by a runtime check: `Publish` only ever builds a `plugin.*` topic (`kernel-callbacks.md#publish`), so there is no code path through which a plugin-originated `Publish` call could produce a `kernel.*` topic. The only kernel-side publisher defined so far is `Emit`'s post-write republish onto `kernel.event.{kind}` (`kernel-callbacks.md#emit`); the namespace is reserved now so that a future kernel-originated topic (a turn-lifecycle notification, for instance) has a home without a naming collision, even though no second publisher exists yet. + +A topic segment is `[a-z0-9_]+`; the dot is the only separator, and `*` is never a literal character within a segment — it exists only as the special last-position wildcard described below. + +## Filter grammar + +`SubscribeRequest.topic_filters` (`kernel-callbacks.md#subscribe`) is a non-empty list of filters. Each filter is one of: + +- **An exact topic** — matches only that literal string. +- **A trailing-wildcard prefix** — a filter ending in `.*` matches any topic sharing every segment before the `*`. `plugin.tool.github.*` matches `plugin.tool.github.file_changed` and `plugin.tool.github.pr_opened`, but not `plugin.tool.gitlab.file_changed` (different segment) and not `plugin.tool.github` itself (the wildcard requires at least one further segment to match against). + +No other wildcard form exists in v1 — no mid-string wildcard, no multi-segment wildcard (no MQTT-style `#`), no negation. A subscriber wanting "every event from this one plugin" uses `plugin.{category}.{name}.*`; a subscriber wanting "every kernel event" uses `kernel.*`. This is a deliberately small grammar: the two real use cases identified so far (all events from one plugin, all events in one kernel-reserved family) are both trailing-prefix matches, and a richer grammar is added only once a use case that needs one actually surfaces. + +## Delivery semantics + +- **Best-effort, not guaranteed.** A `Publish` call returns as soon as the kernel has fanned the event out to every currently-subscribed stream's queue; it does not wait for any subscriber to actually receive or process the event, and a subscriber that connects after a `Publish` call already returned never sees that event. There is no backlog and no replay — this is `internal/eventbus`'s own ephemeral contract, inherited unchanged. +- **Per-subscriber ordering only.** A single `Subscribe` stream sees events matching its filters in the order they were published; there is no ordering guarantee across two different `Subscribe` streams, and no ordering guarantee relative to `ReadEvents` or anything hook-dispatch related. Nothing here carries a `sequence` number — [`determinism.md`](../.claude/rules/determinism.md)'s ordering-authority rule governs persisted, replay-critical ordering, and this bus persists nothing and participates in no replay, so it is deliberately outside that rule's scope, exactly as `internal/eventbus`'s own design notes already state. +- **Observe-only, never a veto.** Unlike a hook subscriber in `veto` mode, a bus subscriber cannot block, modify, or reject the event it received — the bus has no response channel for that at all. A slow or broken subscriber can only ever affect itself (see "Backpressure" below), never the publisher or any other subscriber. + +## Backpressure + +`internal/eventbus`'s own contract is unbounded, never-blocking, never-dropping: a slow in-process subscriber's queue simply grows, because the kernel controls that subscriber's goroutine and its memory is the kernel's own to spend. That guarantee does not survive crossing a subprocess boundary unchanged: a `Subscribe` stream is driven by a remote plugin process the kernel does not control, and an unbounded queue behind a hung or dead plugin's stream would be unbounded kernel-side memory growth driven by a party outside the kernel's control — a materially different risk than a slow in-process handler. + +The RPC bridge therefore imposes a **per-stream bound**: once a `Subscribe` stream's undelivered-event queue exceeds that bound, the kernel terminates the stream with `codes.ResourceExhausted` rather than continuing to grow it, and increments a dedicated metric so a slow-consumer disconnect is observable rather than silent. `internal/eventbus`'s own never-drop guarantee is unchanged by this — the bound lives entirely in the bridge layered on top, and an in-process `Subscribe` (the bridge's own `Handler`) is still never blocked or dropped from the `Bus`'s point of view; only the plugin-facing stream can be closed. A plugin that needs to survive a bounded disconnect resubscribes; it has already lost nothing durable, because nothing on this bus was ever durable in the first place. + +The per-stream bound is an `event_bus{}` config value (`configuration/blocks-reference.md`), not a wire field — a subscriber cannot request a larger bound for itself. + +## The kernel namespace + +`kernel.*` is reserved, subscribable, and never publishable by a plugin (see "Topic grammar" above). Its topic set grows as kernel-side publishers are added; the only one defined at this revision: + +- **`kernel.event.{kind}`** — republished by `Emit` immediately after a successful persisted write, where `{kind}` is the lowercase text form of the persisted `EventKind` (`state-backend.md#the-kind-enum`'s own vocabulary — e.g. `kernel.event.tool_call`, `kernel.event.message`). This lets a plugin observe the durable event stream live without polling `ReadEvents`, while the durability guarantee is untouched: the sqlite row is committed before the republish happens, so a subscriber that never connects, or that disconnects mid-stream, loses nothing durable — it can always fall back to `ReadEvents` for anything it missed. + +A future kernel-originated topic (a turn-lifecycle notification, a plan-ready signal mirrored onto the bus for observability) has a home in this namespace without a naming collision with any plugin's own topics, since no plugin can ever construct a `kernel.*` topic. + +## Open questions + +- **Authorization.** Any plugin can currently `Subscribe` to `plugin.*` (or `plugin.{category}.{name}.*` for a specific other plugin) and observe every other plugin's published events — there is no `agent.hcl`-declared allowlist gating who may subscribe to whom, unlike hook dispatch's static, declared subscriber list. This revision ships that gap open deliberately rather than half-building an authorization model: the kernel logs every `Subscribe` call's resolved filters at `INFO`, so an operator can audit who is listening, but nothing prevents it. A per-plugin subscribe allowlist in `agent.hcl` is the obvious future control, tracked here as a named extension rather than built now. +- **Whether `kernel.*` should eventually carry more than event republishing** — a turn-lifecycle or plan-ready signal mirrored onto the bus purely for observability tooling, distinct from the hook points of the same names that already fire for dispatch purposes. No second publisher exists yet; this section is a placeholder for when one is proposed, not a commitment that one will be. diff --git a/docs/specifications/frontend/README.md b/docs/specifications/frontend/README.md index a282e2b..eb3a77a 100644 --- a/docs/specifications/frontend/README.md +++ b/docs/specifications/frontend/README.md @@ -3,15 +3,15 @@ Covers **two** plugin categories in one directory, both concerned with what the operator sees and does, neither owning the agent loop itself: - **Frontend provider** ([`frontend-protocol.md`](frontend-protocol.md)) — owns the terminal (or window, or voice channel): the process the kernel's state-event stream attaches to, responsible for actually painting pixels/text and turning operator input into `ClientEvent`s. -- **Widget provider** ([`widget-protocol.md`](widget-protocol.md)) — contributes content *into* whichever frontend is active, without owning it. This is a genuine sixth plugin category, not merely an extension of the other five (see [`architecture.md`](../architecture.md#the-six-provider-categories)) — a git-status panel or a context-budget indicator isn't naturally "a tool" or "a context provider," it just wants to put something on screen. +- **Widget provider** ([`widget-protocol.md`](widget-protocol.md)) — contributes content *into* whichever frontend is active, without owning it. This is a genuine sixth plugin category, not merely an extension of the other six (see [`architecture.md`](../architecture.md#the-seven-provider-categories)) — a git-status panel or a context-budget indicator isn't naturally "a tool" or "a context provider," it just wants to put something on screen. Both categories share one vocabulary, formalized once and reused verbatim: the [`RenderTree`](render-tree.md#rendertree) intermediate representation (text runs, code blocks, diffs, tables, links, a sub-session node, an interactive `action` node) and the region/placement model ([`render-tree.md`](render-tree.md#placement--regions)). Every other plugin category's optional `Render` RPC — [`model/protocol.md#render`](../model/protocol.md#render), [`tool/protocol.md#render`](../tool/protocol.md#render), and the equivalent sections in `context/` and `memory/` — returns exactly this type; this directory is where the type itself is formally, canonically defined. -The wire protocol for both categories, plus the shared `RenderTree` IR and the `SlashCommandSpec` type, is defined as gRPC services with protobuf messages — see [`examples.md`](examples.md) for the schema. `RenderTree` and `SlashCommandSpec` are deliberately factored into their own shared vocabulary rather than nested inside the frontend or widget definitions: both the frontend and widget protocols place content into the same `Region` vocabulary, and every provider category (not just frontend/widget) declares a `SlashCommandSpec` in its capability response — see [`frontend-protocol.md#slash-commands`](frontend-protocol.md#slash-commands). +The wire protocol for both categories, plus the shared `RenderTree` IR, is defined as gRPC services with protobuf messages — see [`examples.md`](examples.md) for the schema. `RenderTree` is deliberately factored into its own shared vocabulary rather than nested inside the frontend or widget definitions: both the frontend and widget protocols place content into the same `Region` vocabulary. Slash commands are split the same way content is, but along a different line: a **direct-invoke** command is owned exclusively by the `slashcommand` plugin category — its `SlashCommandSpec` is defined once, canonically, in [`../slashcommand/README.md`](../slashcommand/README.md), not here — while a **prompt-expansion** command is genuinely shared vocabulary, declarable directly in any provider category's own capability response (not just frontend/widget) as a `pluggableharness.common.v1.PromptExpansionSpec` — see [`frontend-protocol.md#slash-commands`](frontend-protocol.md#slash-commands). ## Transport & lifecycle -Subprocess + gRPC via `hashicorp/go-plugin`, per [`architecture.md`](../architecture.md#transport). Standard handshake (magic cookie, protocol version negotiation) applies uniformly across all six provider categories and isn't repeated per category. +Subprocess + gRPC via `hashicorp/go-plugin`, per [`architecture.md`](../architecture.md#transport). Standard handshake (magic cookie, protocol version negotiation) applies uniformly across all seven provider categories and isn't repeated per category. The two categories' primary RPC has **different streaming shapes**, and this is the one thing about this directory most worth getting right, since it's easy to conflate the two: @@ -31,7 +31,7 @@ Both plugins MAY additionally implement `Render`, per the general Emit→Render ## Category structure - [`render-tree.md`](render-tree.md) — the `RenderTree` IR itself: every node type, the placement/region vocabulary, schema versioning for opaque `Render` payloads, and why both categories share one definition. The canonical reference every other category's `Render` points back to. -- [`frontend-protocol.md`](frontend-protocol.md) — the frontend provider protocol: transport, the connection-scoped multiplexed `Attach` stream, fast-path text deltas vs. full `Render`, full session lifecycle (create/attach/resume/detach/list, backfill, no deletion), session scope/multi-attach, slash commands (`SlashCommandSpec`, defined once, canonically, here), the `plan_decision.corrected_input`/`scope` redirect, and the error taxonomy for this category. +- [`frontend-protocol.md`](frontend-protocol.md) — the frontend provider protocol: transport, the connection-scoped multiplexed `Attach` stream, fast-path text deltas vs. full `Render`, full session lifecycle (create/attach/resume/detach/list, backfill, no deletion), session scope/multi-attach, slash commands (registry aggregation across the direct-invoke and prompt-expansion lists, `PromptExpansionSpec` defined once, canonically, here), the `plan_decision.corrected_input`/`scope` redirect, and the error taxonomy for this category. - [`widget-protocol.md`](widget-protocol.md) — the widget provider protocol: transport (server-streaming, not bidi), deriving display state from `observe`-mode hooks with no new data feed, interactive widgets via the `action` `RenderNode`, and the `WidgetError` taxonomy. - [`examples.md`](examples.md) — wire-protocol excerpts for all three schemas, a worked frontend `Attach` sequence (attach → backfill → plan-ready → render → approve/reject/edit), and a worked widget example (a status-bar widget deriving state from the same event stream a frontend sees). - [`conformance.md`](conformance.md) — the error taxonomy for both categories, the MUST/SHOULD/MAY summary matrix, and any genuinely open questions. diff --git a/docs/specifications/frontend/conformance.md b/docs/specifications/frontend/conformance.md index 3eef6ea..2ec2510 100644 --- a/docs/specifications/frontend/conformance.md +++ b/docs/specifications/frontend/conformance.md @@ -40,14 +40,15 @@ A `Configure`-time `FrontendError` surfaces as a gRPC status carrying the error | Widget `Attach` is server-streaming only (no bidi channel), and remains session-scoped, not connection-multiplexed | MUST | [`widget-protocol.md`](widget-protocol.md#transport) | | Widget-initiated actions via `ActionNode` + frontend `action_trigger`, not a widget-specific RPC | MUST | [`widget-protocol.md`](widget-protocol.md#interactive-widgets) | | Widgets derive state via `observe`-mode hooks, no separate data feed | MUST | [`widget-protocol.md`](widget-protocol.md#deriving-display-state--no-new-data-feed) | -| `slash_commands` declarable by any provider category | MUST | [`frontend-protocol.md`](frontend-protocol.md#slash-commands) | -| Slash-command name collision across providers | MUST be config-load-time error | [`frontend-protocol.md`](frontend-protocol.md#slash-commands) | -| Aggregate `SlashCommandRegistry` sent on session attach and on registry change | MUST | [`frontend-protocol.md`](frontend-protocol.md#slash-commands) | -| `direct_invoke` dispatch bypasses the model turn | MUST | [`frontend-protocol.md`](frontend-protocol.md#slash-commands) | -| `prompt_expansion` dispatch costs a model turn | MUST | [`frontend-protocol.md`](frontend-protocol.md#slash-commands) | -| `prompt_expansion` scoping via `agent_profile.slash_commands` | MUST | [`frontend-protocol.md`](frontend-protocol.md#slash-commands), [`configuration/agent-profiles.md`](../configuration/agent-profiles.md) | +| `PromptExpansionSpec` declarable by any provider category | MUST | [`frontend-protocol.md`](frontend-protocol.md#slash-commands) | +| `SlashCommandSpec` (direct-invoke) declared exclusively by a `slashcommand.v1` provider | MUST | [`frontend-protocol.md`](frontend-protocol.md#slash-commands) | +| Slash-command name collision, checked jointly across the direct-invoke and prompt-expansion lists | MUST be config-load-time error | [`frontend-protocol.md`](frontend-protocol.md#slash-commands) | +| Aggregate `SlashCommandRegistry` (both lists) sent on session attach and on registry change | MUST | [`frontend-protocol.md`](frontend-protocol.md#slash-commands) | +| Direct-invoke dispatch, via the owning `slashcommand.v1` provider's `SlashCommandService.Invoke`, bypasses the model turn | MUST | [`frontend-protocol.md`](frontend-protocol.md#slash-commands) | +| Prompt-expansion dispatch costs a model turn | MUST | [`frontend-protocol.md`](frontend-protocol.md#slash-commands) | +| Prompt-expansion scoping via `agent_profile.slash_commands` | MUST | [`frontend-protocol.md`](frontend-protocol.md#slash-commands), [`configuration/agent-profiles.md`](../configuration/agent-profiles.md) | | `ActionNode`/`ActionTrigger` carry `provider`, since `tool_name` is only unique per provider | MUST | [`render-tree.md`](render-tree.md#interactive-content-the-action-node) | -| `ActionNode` dispatches through the same path as `direct_invoke` | MUST | [`render-tree.md`](render-tree.md#interactive-content-the-action-node) | +| `ActionNode` dispatches through the same no-model-turn shape as a direct-invoke `SlashCommandService.Invoke` | MUST | [`render-tree.md`](render-tree.md#interactive-content-the-action-node) | | Reference TUI's region layout | Not normative (one implementation) | [`examples.md`](examples.md#the-reference-tui) | | Structured `FrontendError` taxonomy | MUST | [Error taxonomy](#error-taxonomy) | | Structured `WidgetError` taxonomy | MUST | [Error taxonomy](#error-taxonomy) | diff --git a/docs/specifications/frontend/frontend-protocol.md b/docs/specifications/frontend/frontend-protocol.md index 15dc107..3cf5bdd 100644 --- a/docs/specifications/frontend/frontend-protocol.md +++ b/docs/specifications/frontend/frontend-protocol.md @@ -21,7 +21,7 @@ service FrontendService { `GetCapabilities` returns this frontend's `slash_commands` (see [Slash commands](#slash-commands) below), `ConfigSchema`, `supported_regions`, and `supported_hook_points`; it MUST be cheaply re-queryable and MUST NOT require a network call, the same guarantee [`model/protocol.md#getcapabilities`](../model/protocol.md#getcapabilities) requires of a model provider. `Configure` follows the same contract as [`model/protocol.md#configure`](../model/protocol.md#configure): a config value decoded from the provider's `agent.hcl` block via the schema-to-cty bridge, rejected with a structured error at configure time rather than deferred to the first `Attach`, and never echoing a received secret back out through any event, render, or log line. -`Describe` reports this plugin build's own identity — `{name, version, source, category, protocol_version}` — directly from the running process, rather than the kernel inferring it from a lock-file row. Every one of the six category protocols gains this identical RPC in this protocol revision; it exists specifically for a `dev_overrides`-resolved binary, which has no `provider {}` lock-file entry to read identity from at all (`configuration/lock-file.md`'s "`dev_overrides` and identity without a lock entry"). +`Describe` reports this plugin build's own identity — `{name, version, source, category, protocol_version}` — directly from the running process, rather than the kernel inferring it from a lock-file row. Every one of the seven category protocols gains this identical RPC in this protocol revision; it exists specifically for a `dev_overrides`-resolved binary, which has no `provider {}` lock-file entry to read identity from at all (`configuration/lock-file.md`'s "`dev_overrides` and identity without a lock entry"). ## Fast path vs. full render @@ -167,7 +167,7 @@ message ClientEvent { } ``` -`ActionTrigger` is what a frontend dispatches when the operator activates a [`RenderTree`'s `ActionNode`](render-tree.md#interactive-content-the-action-node); `node_id`, `tool_name`, `args`, and `provider` are echoed unchanged from the originating node — `provider` disambiguates which provider's operation to invoke, since `tool_name` is only unique per provider. The kernel handles the resulting `action_trigger` identically to a `direct_invoke` slash command (below): the normal `Invoke`/plan-apply pipeline, including policy evaluation, with no model turn. +`ActionTrigger` is what a frontend dispatches when the operator activates a [`RenderTree`'s `ActionNode`](render-tree.md#interactive-content-the-action-node); `node_id`, `tool_name`, `args`, and `provider` are echoed unchanged from the originating node — `provider` disambiguates which provider's operation to invoke, since `tool_name` is only unique per provider. The kernel handles the resulting `action_trigger` through the normal `tool.v1` `Invoke`/plan-apply pipeline, including policy evaluation, with no model turn — the same no-model-turn dispatch shape a direct-invoke slash command's own `SlashCommandService.Invoke` ([Slash commands](#slash-commands) below) takes. ### UserMessage carries ContentBlocks @@ -224,34 +224,39 @@ A plain `AttachSession` (as opposed to `ResumeSession`) against a terminal sessi ## Slash commands -`SlashCommandSpec` is defined once, canonically, here — every other category's capability response ([`model/protocol.md#getcapabilities`](../model/protocol.md#getcapabilities), [`tool/protocol.md#getschema`](../tool/protocol.md#getschema), and the equivalent sections in `context/` and `memory/`) declares an optional `[]SlashCommandSpec` field of this same type and links back here rather than redefining it. The wire type is factored out into its own shared vocabulary for exactly that reason: it's shared by every provider category's `GetCapabilities`/`GetSchema` response, not owned by the frontend category alone. +A slash command is one of two distinct kinds, declared and dispatched differently: + +- A **direct-invoke** command — a tool-shaped operation invoked without a model turn — is declared exclusively by a `pluggableharness.slashcommand.v1` provider's own `GetCapabilities` response, and invoked via that same provider's own `SlashCommandService.Invoke`. `SlashCommandSpec` and `SlashCommandService` are defined canonically in [`../slashcommand/protocol.md`](../slashcommand/protocol.md), not here. +- A **prompt-expansion** command — a static template the kernel expands and submits as an ordinary `user_message`, costing a model turn — is genuinely shared vocabulary: any provider category's own capability response (`model/protocol.md#getcapabilities`, `tool/protocol.md#getschema`, and the equivalent sections in `context/` and `memory/`, as well as this category's own `FrontendCapabilities.slash_commands`) MAY declare one, as a `pluggableharness.common.v1.PromptExpansionSpec`. That type is defined below, since it has no dependency on `slashcommand.v1`'s own vocabulary. + +The kernel aggregates every loaded provider's declared commands, of both kinds, into one profile-scoped `SlashCommandRegistry`: ```protobuf -enum Dispatch { - DISPATCH_UNSPECIFIED = 0; - DISPATCH_DIRECT_INVOKE = 1; - DISPATCH_PROMPT_EXPANSION = 2; +message SlashCommandRegistry { + repeated pluggableharness.slashcommand.v1.SlashCommandSpec direct_invoke_commands = 1; // every direct-invoke command, from every loaded slashcommand.v1 provider + repeated pluggableharness.common.v1.PromptExpansionSpec prompt_expansion_commands = 2; // every prompt-expansion command, from every loaded provider category } +``` + +sent to an attaching frontend as part of `session_attached` and again whenever the registry changes (a plugin reload, a config change) — a frontend does not need to separately call every category's `GetCapabilities`/`GetSchema` and merge the results itself. A command's name **MUST** be unique jointly across both lists — a name collision, whether within one list or across the two, **MUST** be a config-load-time error, per this protocol series' established "ambiguity is an error, not a silent pick" pattern. -message SlashCommandSpec { - string name = 1; // invoked as "/name"; MUST be unique across - // every provider loaded in the session +A frontend parses typed input as `/name args`. Resolving `name` means checking both `direct_invoke_commands` and `prompt_expansion_commands` — the joint uniqueness guarantee above means at most one of the two lists can match — and dispatching accordingly: + +- **A `direct_invoke_commands` match**: the frontend maps `args` to the matched `SlashCommandSpec.input_schema` and sends a `ClientEvent.slash_command` naming it; the kernel dispatches it to the owning `slashcommand.v1` provider's `SlashCommandService.Invoke` ([`../slashcommand/protocol.md#invoke`](../slashcommand/protocol.md#invoke)) — the normal plan/apply pipeline, including policy evaluation, with **no model turn**. This is a real behavior difference from an ordinary tool call: the model never sees or decides on this invocation, only its eventual result (appended to history as an ordinary `tool_result`, so the model has full visibility on the *next* turn even though it didn't initiate this one). +- **A `prompt_expansion_commands` match**: the frontend expands that `PromptExpansionSpec.template` with the typed arguments and submits the result as an ordinary `ClientEvent.user_message` — this costs a model turn like any normal message; the only thing the slash command bought was not having to type the full instruction out. + +```protobuf +message PromptExpansionSpec { + string name = 1; // invoked as "/name"; MUST be unique jointly across + // every direct-invoke and prompt-expansion command + // loaded in the session string description = 2; // shown in the hotkey_hints region - Dispatch dispatch = 3; - optional string tool_name = 4; // MUST be set iff dispatch == DISPATCH_DIRECT_INVOKE; - // MUST name one of this SAME provider's own - // tool operations - optional string template = 5; // MUST be set iff dispatch == DISPATCH_PROMPT_EXPANSION; - // "{arg}"-style placeholders substituted from - // the operator's typed arguments + string template = 3; // "{arg}"-style placeholders substituted from + // the operator's typed arguments } ``` -A name collision across providers **MUST** be a config-load-time error, per this protocol series' established "ambiguity is an error, not a silent pick" pattern. The kernel aggregates every loaded provider's declared commands into one profile-scoped `SlashCommandRegistry`, sent to an attaching frontend as part of `session_attached` and again whenever the registry changes (a plugin reload, a config change) — a frontend does not need to separately call every category's `GetCapabilities`/`GetSchema` and merge the results itself. - -- **`DISPATCH_DIRECT_INVOKE`**: the frontend recognizes `/name args`, maps `args` to the named tool's `input_schema`, and dispatches it through the *normal* `Invoke`/plan-apply pipeline ([`agent-loop/plan-apply-gate.md`](../agent-loop/plan-apply-gate.md)) — including policy evaluation — with **no model turn**. This is a real behavior difference from an ordinary tool call: the model never sees or decides on this invocation, only its eventual result (appended to history as an ordinary `tool_result`, so the model has full visibility on the *next* turn even though it didn't initiate this one). -- **`DISPATCH_PROMPT_EXPANSION`**: the frontend expands `template` with the typed arguments and submits the result as an ordinary `ClientEvent.user_message` — this costs a model turn like any normal message; the only thing the slash command bought was not having to type the full instruction out. -- A profile's tool scoping determines which `DISPATCH_DIRECT_INVOKE` commands are available: a command naming a tool absent from the active profile's tool list simply isn't registered for that session. `DISPATCH_PROMPT_EXPANSION` commands have no backing tool to scope against this way, so they're scoped separately, by an explicit `agent_profile.slash_commands` allow-list — see [`configuration/agent-profiles.md`](../configuration/agent-profiles.md) for the block itself. +A profile's provider scoping determines which direct-invoke commands are available: a command whose owning `slashcommand.v1` provider is absent from the active profile's provider list simply isn't registered for that session. Prompt-expansion commands have no backing provider to scope against this way, so they're scoped separately, by an explicit `agent_profile.slash_commands` allow-list — see [`configuration/agent-profiles.md`](../configuration/agent-profiles.md) for the block itself. ## Error taxonomy diff --git a/docs/specifications/frontend/render-tree.md b/docs/specifications/frontend/render-tree.md index b1c4c2f..e38d05d 100644 --- a/docs/specifications/frontend/render-tree.md +++ b/docs/specifications/frontend/render-tree.md @@ -98,7 +98,7 @@ message ActionNode { } ``` -`provider` is the declared name of the tool provider plugin `tool_name` belongs to — `tool_name` is only unique *per provider*, not globally, matching the same naming precedent already established by [`plan/protocol`](../agent-loop/plan-apply-gate.md)'s `PlanItem.provider`. A frontend rendering an `ActionNode` **MUST** make it interactive (a clickable button, a keybindable list item, whatever fits its own UI) and, on activation, **MUST** dispatch a `ClientEvent.action_trigger` carrying that node's `tool_name`/`args`/`provider` unchanged ([`frontend-protocol.md#client-events`](frontend-protocol.md#client-events)). The kernel then handles the resulting `action_trigger` **identically to a `direct_invoke` slash command** ([`frontend-protocol.md#slash-commands`](frontend-protocol.md#slash-commands)): the normal `Invoke`/plan-apply pipeline, including policy evaluation, with no model turn. No action-specific dispatch mechanism exists beyond this — `action` nodes are a second way to *reach* the same dispatch path slash commands already use, via a click instead of typed text. +`provider` is the declared name of the tool provider plugin `tool_name` belongs to — `tool_name` is only unique *per provider*, not globally, matching the same naming precedent already established by [`plan/protocol`](../agent-loop/plan-apply-gate.md)'s `PlanItem.provider`. A frontend rendering an `ActionNode` **MUST** make it interactive (a clickable button, a keybindable list item, whatever fits its own UI) and, on activation, **MUST** dispatch a `ClientEvent.action_trigger` carrying that node's `tool_name`/`args`/`provider` unchanged ([`frontend-protocol.md#client-events`](frontend-protocol.md#client-events)). The kernel then handles the resulting `action_trigger` through the normal `tool.v1` `Invoke`/plan-apply pipeline, including policy evaluation, with no model turn — the same no-model-turn dispatch shape a direct-invoke slash command's own `SlashCommandService.Invoke` ([`../slashcommand/protocol.md#invoke`](../slashcommand/protocol.md#invoke)) takes. No action-specific dispatch mechanism exists beyond this — `action` nodes are a second way to *reach* that same dispatch shape, via a click instead of typed text. This generalizes past widgets for free, since any producer's `Render` output can include an `ActionNode`, not only a widget's: a `tool_result` diff could include an action offering "undo this change," a memory record could offer "forget this," and so on. Widgets are simply the category that motivated adding it — see [`widget-protocol.md#interactive-widgets`](widget-protocol.md#interactive-widgets) for the widget-specific angle. @@ -137,7 +137,7 @@ A frontend that lacks a given region (e.g. a plain line-based CLI with no sideba ## Schema versioning -Every category's own local `RenderRequest` message (`model.proto`, `tool.proto`, `context.proto`, `memory.proto` each declare their own — see each category's `protocol.md#render`) carries a `string schema_version = 2;` field alongside the opaque `payload` this document's package comment and `.claude/rules/grpc.md`'s Emit/Render carve-out already describe. This section is the canonical, single definition of what that string means; every category's `Render` section links back here rather than re-explaining it. +Every category's own local `RenderRequest` message (`model/v1/rpc_request.proto`, `tool/v1/rpc_request.proto`, `context/v1/rpc_request.proto`, `memory/v1/rpc_request.proto` each declare their own — see each category's `protocol.md#render`) carries a `string schema_version = 2;` field alongside the opaque `payload` this document's package comment and `.claude/rules/grpc.md`'s Emit/Render carve-out already describe. This section is the canonical, single definition of what that string means; every category's `Render` section links back here rather than re-explaining it. `schema_version` is round-tripped, not invented at `Render` time. A producer sets it once, at emit time, on [`kernel-callbacks.md#emit`](../kernel-callbacks.md#emit)'s `EmitRequest.schema_version` — "versions the shape of `payload`" — and the kernel persists it verbatim alongside that event's opaque `payload` byte-for-byte, per [`state-backend.md`](../state-backend.md)'s `events.schema_version` column. When the kernel later calls that category's `Render` RPC (live, or as part of a historical replay), it passes the *same* string back on `RenderRequest.schema_version` — the value is never recomputed, guessed, or defaulted by the kernel at render time, only threaded through unchanged from what the producer originally declared. diff --git a/docs/specifications/frontend/widget-protocol.md b/docs/specifications/frontend/widget-protocol.md index e714306..45cd047 100644 --- a/docs/specifications/frontend/widget-protocol.md +++ b/docs/specifications/frontend/widget-protocol.md @@ -6,7 +6,7 @@ The widget provider protocol: a plugin that contributes content *into* whichever Subprocess + gRPC via `hashicorp/go-plugin`. A widget provider plugin exposes four RPCs: `GetCapabilities`, `Configure`, `Attach`, `Describe`. -**Unlike the frontend provider's bidirectional, connection-scoped `Attach` ([`frontend-protocol.md#transport`](frontend-protocol.md#transport)), this `Attach` is server-streaming only and stays session-scoped** (one call per session, per `AttachRequest.session_id`, not multiplexed across sessions on one connection). Widgets are passive/display-only in v1 — a widget wanting to trigger an action (not just display state) does so by *also* being a tool provider with a slash command ([`frontend-protocol.md#slash-commands`](frontend-protocol.md#slash-commands)), not through this channel. Sharing the RPC name `Attach` with the frontend protocol while having a genuinely different streaming shape is a real gotcha worth stating plainly: **frontend `Attach` is bidi and connection-multiplexed, widget `Attach` is neither.** +**Unlike the frontend provider's bidirectional, connection-scoped `Attach` ([`frontend-protocol.md#transport`](frontend-protocol.md#transport)), this `Attach` is server-streaming only and stays session-scoped** (one call per session, per `AttachRequest.session_id`, not multiplexed across sessions on one connection). Widgets are passive/display-only in v1 — a widget wanting to trigger an action (not just display state) does so by *also* being a slashcommand provider ([`../slashcommand/protocol.md`](../slashcommand/protocol.md)), implementing `SlashCommandService` directly, not through this channel. A widget MAY implement `WidgetService` and `SlashCommandService` in the same plugin process — `hashicorp/go-plugin` natively muxes multiple gRPC services over one subprocess connection, the same precedent `HookSubscriberService` already establishes ([`agent-loop/hook-dispatch.md`](../agent-loop/hook-dispatch.md)). Sharing the RPC name `Attach` with the frontend protocol while having a genuinely different streaming shape is a real gotcha worth stating plainly: **frontend `Attach` is bidi and connection-multiplexed, widget `Attach` is neither.** ```protobuf service WidgetService { @@ -31,7 +31,7 @@ message WidgetCapabilities { `Configure` follows the same contract as [`model/protocol.md#configure`](../model/protocol.md#configure): config decoded from the widget's `agent.hcl` block, rejected with a structured error at configure time rather than deferred, never echoing a received secret back out. -`Describe` reports this plugin build's own identity — `{name, version, source, category, protocol_version}` — directly from the running process, rather than the kernel inferring it from a lock-file row. Every one of the six category protocols gains this identical RPC in this protocol revision; it exists specifically for a `dev_overrides`-resolved binary, which has no `provider {}` lock-file entry to read identity from at all (`configuration/lock-file.md`'s "`dev_overrides` and identity without a lock entry"). +`Describe` reports this plugin build's own identity — `{name, version, source, category, protocol_version}` — directly from the running process, rather than the kernel inferring it from a lock-file row. Every one of the seven category protocols gains this identical RPC in this protocol revision; it exists specifically for a `dev_overrides`-resolved binary, which has no `provider {}` lock-file entry to read identity from at all (`configuration/lock-file.md`'s "`dev_overrides` and identity without a lock entry"). `Attach` opens a server-streaming feed of this widget's rendered updates for one session: @@ -60,7 +60,7 @@ A widget provider gets no special session-state API. It is implicitly available ## Interactive widgets -A widget's `WidgetUpdate.content` **MAY** include [`ActionNode`s](render-tree.md#interactive-content-the-action-node) the same way any other `RenderTree` can — no widget-specific protocol addition was needed beyond the general `action` node mechanism ([`render-tree.md#interactive-content-the-action-node`](render-tree.md#interactive-content-the-action-node)). A clickable sidebar item (a widget offering "dismiss," "retry," or "open in editor," for example) is expressed exactly like an action node contributed by any other producer: the frontend renders it interactive, and on activation dispatches `ClientEvent.action_trigger` ([`frontend-protocol.md#client-events`](frontend-protocol.md#client-events)), which the kernel handles identically to a `direct_invoke` slash command — the normal `Invoke`/plan-apply pipeline, including policy evaluation, with no model turn. +A widget's `WidgetUpdate.content` **MAY** include [`ActionNode`s](render-tree.md#interactive-content-the-action-node) the same way any other `RenderTree` can — no widget-specific protocol addition was needed beyond the general `action` node mechanism ([`render-tree.md#interactive-content-the-action-node`](render-tree.md#interactive-content-the-action-node)). A clickable sidebar item (a widget offering "dismiss," "retry," or "open in editor," for example) is expressed exactly like an action node contributed by any other producer: the frontend renders it interactive, and on activation dispatches `ClientEvent.action_trigger` ([`frontend-protocol.md#client-events`](frontend-protocol.md#client-events)), which the kernel handles through the normal `tool.v1` `Invoke`/plan-apply pipeline, including policy evaluation, with no model turn — the same no-model-turn dispatch shape a direct-invoke slash command's own `SlashCommandService.Invoke` ([`../slashcommand/protocol.md#invoke`](../slashcommand/protocol.md#invoke)) takes. This was the specific gap that motivated adding `ActionNode` to [`render-tree.md`](render-tree.md) in the first place — widgets needed a way to trigger something, not just display state — but the resulting mechanism generalizes past widgets: any producer's rendered content can offer a one-click follow-up action, not only widget-contributed panels. diff --git a/docs/specifications/glossary.md b/docs/specifications/glossary.md index d22319f..5267445 100644 --- a/docs/specifications/glossary.md +++ b/docs/specifications/glossary.md @@ -4,8 +4,8 @@ Terminology used throughout `docs/specifications/`. | Term | Meaning | |---|---| -| **Provider** | A plugin binary implementing one of the six categories: model, tool, memory, context, frontend, widget. | -| **Category** | One of the six provider kinds above, each with its own protocol (`model/`, `tool/`, `memory/`, `context/`, `frontend/` — widget is documented alongside frontend). | +| **Provider** | A plugin binary implementing one of the seven categories: model, tool, memory, context, frontend, widget, slashcommand. | +| **Category** | One of the seven provider kinds above, each with its own protocol (`model/`, `tool/`, `memory/`, `context/`, `frontend/` — widget is documented alongside frontend — `slashcommand/`). | | **Resource** | A tool operation that **mutates** state — gated behind the plan/apply flow. See [`agent-loop/plan-apply-gate.md`](agent-loop/plan-apply-gate.md). | | **Data source** | A tool operation that only **reads** — executes freely (subject to a policy precheck, not a plan/apply gate), feeds the plan. | | **Interactive** | A tool kind for calls that neither read nor write state but require a human response mid-turn (e.g. `ask_user`). See [`tool/protocol.md`](tool/protocol.md#kind-interactive) and [`agent-loop/plan-apply-gate.md`](agent-loop/plan-apply-gate.md). | @@ -30,3 +30,7 @@ Terminology used throughout `docs/specifications/`. | **Schema-to-cty bridge** | The mechanism translating a provider's declared config schema into an `hcldec` spec so `agent.hcl` provider blocks decode through real HCL2/`cty`, distinct from the JSON-Schema subset tool authors use for LLM function-calling. See [`configuration/blocks-reference.md`](configuration/blocks-reference.md). | | **Canonical message** | The kernel's internal content-block message representation (`text`, `tool_use`, `tool_result`, `image`, `thinking`, `redacted_thinking`) — the state backend's source of truth, independent of any one vendor's wire format. See [`model/data-types.md`](model/data-types.md). | | **Lock file** | `.agent/agent.lock.hcl` — pins resolved provider version, source, and checksum per provider, mirroring `.terraform.lock.hcl`. See [`configuration/lock-file.md`](configuration/lock-file.md). | +| **Event bus** | The ephemeral, best-effort, cross-plugin publish/subscribe primitive behind `Publish`/`Subscribe` — distinct from `Emit` (durable), hook dispatch (synchronous, `agent.hcl`-declared), and frontend broadcast (connection-scoped). See [`event-bus.md`](event-bus.md). | +| **Topic** | A dot-separated string identifying an event-bus channel — `plugin.{category}.{name}.{event_type}` for a plugin-published event, `kernel.*` reserved for the kernel. See [`event-bus.md#topic-grammar`](event-bus.md#topic-grammar). | +| **Publish** / **Subscribe** | The kernel callback primitives that put an event onto the bus and receive a live stream of events matching a topic filter, respectively. See [`kernel-callbacks.md`](kernel-callbacks.md) and [`event-bus.md`](event-bus.md). | +| **Telemetry relay** | A plugin's own trace spans and metric observations reaching the operator's configured collector via the kernel (`ExportSpans`/`RecordMetrics`), rather than each plugin process exporting OTLP directly. See [`observability.md`](observability.md). | diff --git a/docs/specifications/kernel-callbacks.md b/docs/specifications/kernel-callbacks.md index 242f4dc..8a5c5d3 100644 --- a/docs/specifications/kernel-callbacks.md +++ b/docs/specifications/kernel-callbacks.md @@ -1,13 +1,17 @@ # Kernel callback service -This formalizes the **plugin-to-kernel** direction of communication — the reverse of every other protocol in this series, which covers kernel-to-plugin RPCs (`GetCapabilities`, `Configure`, `StreamCompletion`, `Invoke`, `Attach`, and so on). Four primitives live here: +This formalizes the **plugin-to-kernel** direction of communication — the reverse of every other protocol in this series, which covers kernel-to-plugin RPCs (`GetCapabilities`, `Configure`, `StreamCompletion`, `Invoke`, `Attach`, and so on). Twelve primitives live here, grouped by concern: - **`RunSession`** — runs a full agent session on a plugin's behalf. Used for sub-agent spawns today, and reserved for a future non-interactive pipeline mode. Full turn-by-turn semantics live in [`agent-loop/subagents.md`](agent-loop/subagents.md); this document defines only the wire-level calling mechanism. `RunSessionResult` carries, alongside `final_message` and `status`, three aggregate usage fields — `total_cost_usd`, `total_input_tokens`, `total_output_tokens` — summed across every turn the child session ran, including any of its own descendant sub-agent sessions. These are deliberately flat fields, not a reference to a single completion's `Usage` shape: `RunSessionResult` reports a whole-session rollup (the same `cost_ledger` SUM [`state-backend.md`](state-backend.md) already computes), a different thing from one model call's per-call token counts. The rollup lets a calling plugin do budget-aware fan-out — checking a just-finished child's actual spend before deciding whether to spawn another — without separately re-summing the child's event history itself. - **`CountTokens`** — resolves an exact-if-possible token count for a string, the shared primitive every other category's `tokens` field routes through. - **`Emit`** — how a plugin persists anything into the session's state backend. - **`Log`** — carries a plugin's own log output into the kernel's centralized logging, so it doesn't vanish into an unread subprocess stderr. +- **`ExportSpans`** / **`RecordMetrics`** / **`GetTelemetryConfig`** — the observability relay: a plugin's own tracing and metrics flow through the kernel rather than exporting off-process directly. See [`observability.md`](observability.md) for why and for the tracing/metrics asymmetry. +- **`GetConfig`** — returns the calling plugin's own resolved `agent.hcl` configuration, the same already-decoded shape `Configure` received. +- **`Publish`** / **`Subscribe`** — the event bus: ephemeral, best-effort, cross-plugin pub/sub, distinct from `Emit`'s durable per-session log and from hook dispatch's synchronous, `agent.hcl`-declared subscriber chain. See [`event-bus.md`](event-bus.md). +- **`ReadEvents`** / **`GetSession`** — read-back primitives over the calling plugin's own session: its persisted event log, and its metadata plus live budget rollups. -See [`glossary.md`](glossary.md) for how these four terms fit the wider vocabulary, and [`architecture.md`](architecture.md) for the surrounding system (transport, hook dispatch, plan/apply, state backend). +See [`glossary.md`](glossary.md) for how these terms fit the wider vocabulary, and [`architecture.md`](architecture.md) for the surrounding system (transport, hook dispatch, plan/apply, state backend). ## The callback channel @@ -15,19 +19,32 @@ See [`glossary.md`](glossary.md) for how these four terms fit the wider vocabula ```protobuf KernelCallbackService { - RunSession(RunSessionRequest) -> RunSessionResult // agent-loop/subagents.md — - // full semantics defined - // there, not repeated here - CountTokens(CountTokensRequest) -> CountTokensResult // see "CountTokens" below - Emit(EmitRequest) -> EmitResult // see "Emit" below - Log(LogRequest) -> LogResult // see "Log" below + RunSession(RunSessionRequest) -> RunSessionResult // agent-loop/subagents.md — + // full semantics defined + // there, not repeated here + CountTokens(CountTokensRequest) -> CountTokensResult // see "CountTokens" below + Emit(EmitRequest) -> EmitResult // see "Emit" below + Log(LogRequest) -> LogResult // see "Log" below + ExportSpans(ExportSpansRequest) -> ExportSpansResult // see "ExportSpans" below + RecordMetrics(RecordMetricsRequest) -> RecordMetricsResult // see "RecordMetrics" below + GetTelemetryConfig(GetTelemetryConfigRequest) -> GetTelemetryConfigResult // see "GetTelemetryConfig" below + GetConfig(GetConfigRequest) -> GetConfigResult // see "GetConfig" below + Publish(PublishRequest) -> PublishResult // see "Publish" below + Subscribe(SubscribeRequest) -> stream BusEvent // see "Subscribe" below + ReadEvents(ReadEventsRequest) -> stream StoredEvent // see "ReadEvents" below + GetSession(GetSessionRequest) -> GetSessionResult // see "GetSession" below } ``` -This channel and the frontend provider's `Attach` RPC are the **only** two genuinely bidirectional RPCs in the whole system — every other category RPC is server-streaming or unary. A new primitive added to this service does not get to default to bidi "just in case"; the shape here is a consequence of `hashicorp/go-plugin`'s native plugin→kernel channel, not a free design choice repeated per RPC. +This channel and the frontend provider's `Attach` RPC are the **only** two genuinely bidirectional RPCs in the whole system — every other category RPC, and every RPC on this service, is server-streaming or unary. `Subscribe` and `ReadEvents` are server-streaming; the other ten are unary. A new primitive added to this service does not get to default to bidi "just in case"; the shape here is a consequence of `hashicorp/go-plugin`'s native plugin→kernel channel existing at all, not a free design choice repeated per RPC — and even that channel's own two RPCs (`RunSession`, `CountTokens`) are, at the application level, simple request/response calls riding a connection that happens to be bidirectional at the transport layer, not calls that themselves stream both ways. The callback channel uses a **fixed, well-known broker ID**, not a wire-negotiated one — safe because the kernel is the only party that ever accepts this broker connection, so no collision is possible. Producer identity (`{category, name, version}`) is a property of *which broker connection a call arrived on*, established at handshake, and is server-derived, never client-supplied (see "Emit" and "Log" below) — a plugin cannot declare a producer identity other than its own. +**Every RPC on this service falls into one of two shapes with respect to session scope**, and this determines whether a `session_id` field appears on its request at all: + +- **Plugin-scoped** — about the calling plugin itself, true regardless of which (if any) session is currently invoking it: `CountTokens`, `GetConfig`, `GetTelemetryConfig`, `Publish`, `Subscribe`. These carry no `session_id` field. +- **Session-scoped** — about one specific session the plugin is participating in: `RunSession` (the *parent*, via `parent_session_id`), `Emit`, `Log` (optionally), `ExportSpans`/`RecordMetrics` (optionally, same reasoning as `Log`), `ReadEvents`, `GetSession`. These carry an explicit `session_id` field, because a single long-lived plugin subprocess can be invoked by more than one session over its lifetime (concurrent parallel data-source calls in one turn, or nested `RunSession` spawns) — the kernel has no other way to know which session a given call pertains to. Every mandatory-`session_id` RPC follows `Emit`'s rule: the kernel MUST reject a call naming any session other than the one the calling plugin was actually invoked for. + ## `CountTokens` ```protobuf @@ -104,7 +121,7 @@ EmitResult { } ``` -`producer_category`/`producer_name`/`producer_version` are deliberately **not** `EmitRequest` fields: the kernel already knows which plugin is calling — it's a property of the already-authenticated callback connection established at handshake (see "The callback channel" above) — and fills them in server-side. A plugin cannot declare a producer identity other than its own; there's no field to spoof. The same rule applies to `Log` below. +`producer_category`/`producer_name`/`producer_version` are deliberately **not** `EmitRequest` fields: the kernel already knows which plugin is calling — it's a property of the already-authenticated callback connection established at handshake (see "The callback channel" above) — and fills them in server-side. A plugin cannot declare a producer identity other than its own; there's no field to spoof. The same rule applies to `Log` and every other RPC on this service. `EventKind` is `state-backend.md`'s authoritative enum, restated here only because it's the wire-level type `Emit` actually carries — this document does not own its definition, and `state-backend.md` remains authoritative. Like every enum in this system, `EventKind`'s zero value, `EVENT_KIND_UNSPECIFIED`, is never valid on the wire — a caller that forgets to set `kind` produces a detectable, named "unspecified" error rather than something that silently looks like a real event kind. Usage/cost, `Render` output, and `session_start`/`session_end` deliberately don't get their own `EventKind` at all — see [`state-backend.md#the-kind-enum`](state-backend.md#the-kind-enum) for why. @@ -112,22 +129,29 @@ EmitResult { `Emit` accepts `EVENT_KIND_HOOK_ERROR` like any other kind, with one difference: the kernel is the one calling it, on a failing hook subscriber's behalf, rather than a plugin calling `Emit` for itself — see [`state-backend.md#the-kind-enum`](state-backend.md#the-kind-enum) for why this kind is kernel-synthesized and [`agent-loop/hook-dispatch.md#subscriber-error-handling`](agent-loop/hook-dispatch.md#subscriber-error-handling) for when it fires. +A successful `Emit` MUST also republish the same event onto the event bus (see [`event-bus.md#the-kernel-namespace`](event-bus.md#the-kernel-namespace)) on the reserved topic `kernel.event.{kind}`, where `{kind}` is `EventKind`'s lowercase text form (`state-backend.md#the-kind-enum`'s own vocabulary, e.g. `kernel.event.tool_call`). This is the first, and so far only, kernel-side bus publisher — it lets any plugin observe the durable event stream live, without polling `ReadEvents`, while the durability guarantee stays exactly where it already lives: a bus subscriber that never connects, or disconnects mid-stream, loses nothing durable, because the sqlite row was already committed before the republish. + ## Log `Log` carries a plugin's own log output into the kernel's centralized logging, so it doesn't vanish into an unread subprocess stderr — plugin logs and the kernel's own logs end up in one place instead of two. Unlike `Emit`, a `Log` call is not tied to an active session: a plugin MAY call `Log` before any session exists (process startup, or from within `Configure`) or after one has ended (during shutdown), so `session_id` is optional here where it is mandatory for `Emit`. +`Log` carries a **batch** of entries, not a single one — a plugin logging at `TRACE` would otherwise pay one unary round-trip per line, which is untenable. Batching is a transport concern only; it does not change `Log`'s session-optionality or any other per-entry semantics below. + ```protobuf LogRequest { - session_id string? // MAY be omitted — set when the log line is - // attributable to a specific session (the common - // case), omitted for startup/shutdown/Configure-time - // logging that predates or outlives any session - entry LogEntry // MUST — see below + session_id string? // MAY be omitted — set when the log line is + // attributable to a specific session (the common + // case), omitted for startup/shutdown/Configure-time + // logging that predates or outlives any session + entries []LogEntry // MUST — non-empty; one or more entries, in the + // order the plugin produced them } LogResult {} // empty; a Log call either succeeds or the RPC itself errors ``` +A malformed entry within an otherwise-valid batch (missing `level`, missing `message`, missing `time`) is skipped and warned about individually — the kernel MUST NOT fail the whole batch for one bad entry, since that would silently discard every well-formed entry alongside it. `LogRequest` carrying zero entries, or an `entries` list where every single entry is malformed, is itself a malformed request and fails the RPC. + ```protobuf LogEntry { level LogLevel // MUST @@ -145,12 +169,174 @@ LogEntry { } ``` -The six-level vocabulary (`TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`) is the canonical logging vocabulary for the whole project, not just this RPC: kernel-native code, not just forwarded plugin logs, uses these same six levels. `LogLevel`'s zero value, `LOG_LEVEL_UNSPECIFIED`, is never valid on the wire, the same convention every enum in this system follows — a `Log` call that omits `level` is a malformed request, not a silently-defaulted one. +The six-level vocabulary (`TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`) is the canonical logging vocabulary for the whole project, not just this RPC: kernel-native code, not just forwarded plugin logs, uses these same six levels. `LogLevel`'s zero value, `LOG_LEVEL_UNSPECIFIED`, is never valid on the wire, the same convention every enum in this system follows — an entry that omits `level` is malformed (see above), not silently defaulted. `LOG_LEVEL_TRACE` and `LOG_LEVEL_FATAL` do not map directly onto `log/slog`'s four built-in levels (Debug/Info/Warn/Error — the kernel's own logging is slog-only). The kernel MUST translate `LOG_LEVEL_TRACE` to a custom `slog.Level` below `slog.LevelDebug` and `LOG_LEVEL_FATAL` to one above `slog.LevelError`; `slog.Level` is an ordinary `int8` and custom levels are a native, documented pattern, not a workaround. The exact numeric offsets are a kernel implementation detail, not part of this wire protocol. **`LOG_LEVEL_FATAL` is a severity label only.** Logging at `FATAL` does not itself terminate the plugin or the kernel, and the kernel **MUST NOT** treat a `Log` call carrying it as a request to do anything beyond routing the entry through at that severity. This is worth stating unambiguously because "FATAL" naturally reads as "the process should die" to anyone unfamiliar with this design — it isn't, here. A plugin that logs FATAL and then crashes is still detected and categorized through the ordinary process-crash path (`process_crashed`, per [`tool/conformance.md`](tool/conformance.md) and the parallel handling in other categories), never inferred from having received a FATAL log line. +A plugin SHOULD consult `GetTelemetryConfig`'s `log_level` before constructing an entry whose `fields` are expensive to compute, and MAY skip the call entirely for a level below the reported floor — the entry would be logged through and then discarded kernel-side either way, so this is purely an optimization, never a correctness requirement. + +## `ExportSpans` + +`ExportSpans` relays a batch of a plugin's own completed trace spans to the kernel, which is the single place that holds collector configuration and exports to it. See [`observability.md#the-relay-model`](observability.md#the-relay-model) for the full rationale (this reverses an earlier direct-export design) and for why this RPC has no metrics counterpart. + +```protobuf +ExportSpansRequest { + session_id string? // MAY be omitted — same session-optional rule + // as Log; a plugin may produce spans outside + // any session context (Configure, startup) + spans []trace.v1.Span // MUST — non-empty +} + +ExportSpansResult {} // empty; an ExportSpans call either succeeds or the + // RPC itself errors +``` + +The kernel MUST NOT re-parent, re-time, or otherwise alter a relayed span's `trace_id`/`span_id`/`parent_span_id`/timestamps before forwarding it to a collector — it is a transparent relay, not a re-emission through its own tracer. `producer` attribution is added to the exported resource, server-derived exactly as with `Emit`/`Log`, never read from the span itself. + +## `RecordMetrics` + +`RecordMetrics` relays a batch of metric observations. Unlike `ExportSpans`, this is **not** a transparent relay: see [`observability.md#the-tracingmetrics-asymmetry`](observability.md#the-tracingmetrics-asymmetry) for why plugin-supplied metric attributes MUST be bounded by the kernel before they reach any exporter. + +```protobuf +RecordMetricsRequest { + session_id string? // MAY be omitted — same rule as Log + metrics []metric.v1.MetricRecord // MUST — non-empty +} + +RecordMetricsResult {} // empty; same shape as ExportSpansResult +``` + +## `GetTelemetryConfig` + +`GetTelemetryConfig` answers "is tracing/metrics/logging on, and at what level" without a plugin needing to guess from its own environment. A plugin SHOULD call this once at startup and cache the result for its process lifetime rather than calling it per operation — see [`observability.md#gettelemetryconfig-caching`](observability.md#gettelemetryconfig-caching). + +```protobuf +GetTelemetryConfigRequest {} // empty; identity comes from the callback + // connection, not a request field + +GetTelemetryConfigResult { + traces_enabled bool // MUST + metrics_enabled bool // MUST + logs_enabled bool // MUST + log_level LogLevel // MUST — the floor below which a Log entry is + // accepted but immediately discarded; see "Log" + sampling_ratio double // MUST — the ParentBased(TraceIDRatioBased) + // ratio configured for traces, meaningful only + // when traces_enabled +} +``` + +## `GetConfig` + +`GetConfig` returns the calling plugin's own already-decoded `agent.hcl` configuration — the same shape `Configure` received, secrets already resolved through the schema-to-cty bridge (`configuration/blocks-reference.md`). This closes a real gap: before this RPC existed, a plugin that needed its config outside the one `Configure` call it happened to receive it in had no channel to ask for it again. + +```protobuf +GetConfigRequest {} // empty; identity comes from the callback connection + +GetConfigResult { + config Struct // MUST — identical shape to ConfigureRequest.config +} +``` + +**A plugin MUST NOT echo any value from `config` into `Emit`, `Publish`, `Render`, a log line, or an error message** if that value came from a `sensitive` config attribute — the same rule [`model/protocol.md`](model/protocol.md) and [`tool/protocol.md`](tool/protocol.md) already impose on a received secret, restated here because `GetConfig` is a second channel a secret now crosses. Handling this RPC is on the kernel's deliberately-unlogged path ([`configuration/blocks-reference.md`](configuration/blocks-reference.md)'s secret-resolution rule) — the kernel itself MUST NOT log `config`'s contents at any level, including `TRACE`, when serving this call. + +## Publish + +`Publish` emits one event onto the ephemeral, in-process event bus for other plugins (and the kernel) to observe. See [`event-bus.md`](event-bus.md) for the full topic grammar, delivery semantics, and the boundary against `Emit`/hook dispatch/frontend broadcast — this section gives only the wire shape. + +```protobuf +PublishRequest { + event_type string // MUST — a single dot-free, wildcard-free segment + // naming this occurrence within the plugin's own + // namespace, e.g. "file_changed" + payload bytes // MAY be empty; opaque to the kernel — see + // event-bus.md's carve-out + payload_type string // MUST — identifies payload's shape for a + // subscriber: a fully-qualified proto message + // name (preferred) or a media type + schema_version string // MUST — versions payload_type the same way + // EmitRequest.schema_version versions Emit's + // payload +} + +PublishResult { + topic string // the fully-resolved topic this event was published on: + // "plugin.{category}.{name}.{event_type}" +} +``` + +**A plugin never supplies its own topic.** The kernel constructs `plugin.{category}.{name}.{event_type}` from the authenticated callback connection's producer identity, exactly as it derives `producer_category`/`producer_name`/`producer_version` for `Emit` and `Log` — a plugin cannot publish under another plugin's identity, and cannot publish onto the reserved `kernel.*` namespace (see [`event-bus.md#the-kernel-namespace`](event-bus.md#the-kernel-namespace)), because it never has the ability to name a topic outside its own constructed one. + +## Subscribe + +`Subscribe` is server-streaming: the plugin sends one request and receives a live stream of `BusEvent`s matching its filters until it closes the stream or the kernel closes it (see [`event-bus.md#backpressure`](event-bus.md#backpressure) for the one condition under which the kernel closes it unilaterally). + +```protobuf +SubscribeRequest { + topic_filters []string // MUST — non-empty; each entry is either an + // exact topic or a topic prefix ending in "*" + // (event-bus.md's filter grammar) +} + +// streamed: +BusEvent { + topic string // the event's fully-resolved topic + payload bytes // opaque; see Publish + payload_type string // see Publish + schema_version string // see Publish + time Timestamp // when the kernel received the Publish this + // event fans out from +} +``` + +## `ReadEvents` + +`ReadEvents` is server-streaming: it reads back the calling plugin's own session's persisted event log, ordered by `sequence` — never by `time`, per `.claude/rules/determinism.md`'s ordering rule, restated here because this is the one RPC on this service that reads the ordering-authoritative column back out. + +```protobuf +ReadEventsRequest { + session_id string // MUST — same one-session-only rule as Emit + kinds []EventKind // MAY be empty, meaning "every kind" + from_sequence int64? // MAY be omitted, meaning "from the start + // of the session's log" + limit int32? // MAY be omitted, meaning "no limit" +} + +// streamed, ordered by sequence ascending: +StoredEvent { + sequence int64 + id string + time Timestamp // display-only, mirrors state-backend.md's + // events.timestamp column — never used to + // reorder anything + kind EventKind + producer ProducerRef // who originally Emit'd this event — read + // back, not server-derived for this call + schema_version string + payload bytes // opaque, exactly as Emit wrote it +} +``` + +## `GetSession` + +`GetSession` returns the calling plugin's own session's metadata plus its live, in-memory budget rollups — the same [`state-backend.md`](state-backend.md)-backed `SessionInfo` the frontend protocol already uses, extended with two fields that state backend deliberately does not persist ([`state-backend.md#live-vs-post-hoc-tree-walking`](state-backend.md#live-vs-post-hoc-tree-walking)): a session's remaining depth and cost budget are in-memory kernel state, recomputed at spawn time and spent down at each `RunSession` hop, never written to sqlite. + +```protobuf +GetSessionRequest { + session_id string // MUST — same one-session-only rule as Emit +} + +GetSessionResult { + info session.v1.SessionInfo // MUST + remaining_depth int32 // MUST + remaining_cost_budget_usd double // MUST +} +``` + +`info.cost_usd` is the persisted `cost_ledger` SUM (a rollup computed and stored at usage-event time, per `.claude/rules/determinism.md`'s cost-rollup rule) — `GetSession` reads it, it never re-walks and re-sums the event log itself. `remaining_cost_budget_usd` and `remaining_depth`, by contrast, are the live in-memory figures — the two mechanisms answer genuinely different questions, and this RPC deliberately surfaces both rather than picking one. + ## Required vs. optional support | Capability | Level | Notes | @@ -163,13 +349,26 @@ The six-level vocabulary (`TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`) is | Single documented fallback formula, no per-caller variation | MUST | "The fallback heuristic" | | Context/memory providers computing `tokens` via this primitive, not their own heuristic | MUST | "Why a kernel primitive, not a provider-local heuristic" | | `Emit` callable via this channel | MUST | "Emit" | -| Kernel derives producer identity server-side, never from a client-supplied field | MUST | "Emit", "Log" | +| Kernel derives producer identity server-side, never from a client-supplied field | MUST | "Emit", "Log", and every other RPC on this service | +| `Emit` republishes onto `kernel.event.{kind}` on success | MUST | "Emit" | | `Log` callable via this channel | MUST | "Log" | -| `session_id` optional on `Log` (unlike `Emit`, where it's mandatory) | MUST | "Log" | +| `session_id` optional on `Log`/`ExportSpans`/`RecordMetrics` (unlike `Emit`/`ReadEvents`/`GetSession`, where it's mandatory) | MUST | "Log", "ExportSpans", "RecordMetrics" | +| `Log` accepts a batch and skips-and-warns a malformed entry rather than failing the whole batch | MUST | "Log" | | Kernel translates `LOG_LEVEL_TRACE`/`LOG_LEVEL_FATAL` onto custom `slog.Level` values outside slog's 4 built-in levels | MUST | "Log" | | `LOG_LEVEL_FATAL` triggers plugin/kernel termination | MUST NOT | "Log" | +| `ExportSpans` relays without altering span identity/timing | MUST | "ExportSpans" | +| `RecordMetrics` attribute keys bounded kernel-side | MUST | [`observability.md#the-tracingmetrics-asymmetry`](observability.md#the-tracingmetrics-asymmetry) | +| `GetTelemetryConfig` callable via this channel | MUST | "GetTelemetryConfig" | +| `GetConfig` callable via this channel | MUST | "GetConfig" | +| Secrets in `GetConfig`'s result echoed into `Emit`/`Publish`/`Render`/logs/errors | MUST NOT | "GetConfig" | +| `Publish` callable via this channel | MUST | "Publish" | +| Plugin-supplied topic on `Publish` | MUST NOT (kernel constructs it) | "Publish" | +| `Subscribe` callable via this channel | MUST | "Subscribe" | +| `ReadEvents` callable via this channel, ordered by `sequence` | MUST | "ReadEvents" | +| `GetSession` callable via this channel | MUST | "GetSession" | ## Open questions - Whether the kernel should cache `CountTokens` results — the same static content (e.g. a `stability: static` context section, per [`context/README.md`](context/README.md)) being re-counted every turn is wasted work. Not addressed here; a plausible follow-up optimization once a reference implementation exists. -- Whether other future kernel primitives (beyond `RunSession`/`CountTokens`/ `Emit`/`Log`) belong on this same `KernelCallbackService`, or whether some warrant their own dedicated channel — no candidate has surfaced yet, noted for whenever one does. +- Whether other future kernel primitives belong on this same `KernelCallbackService`, or whether some warrant their own dedicated channel. **Resolved in this revision: one service.** The handshake already guarantees every plugin subprocess exactly one callback connection (see "The callback channel"); a second channel would need its own broker ID and its own handshake step for no benefit over adding an RPC here. Should a genuinely different transport shape ever be needed (a primitive that must be bidirectional, for instance), that would be the trigger to revisit this, not RPC count alone. +- `event-bus.md#authorization` tracks whether `Subscribe` needs an `agent.hcl`-declared allowlist, since v1 lets any plugin subscribe to any non-`kernel.*` topic including another plugin's. diff --git a/docs/specifications/memory/README.md b/docs/specifications/memory/README.md index f65f6a0..475339d 100644 --- a/docs/specifications/memory/README.md +++ b/docs/specifications/memory/README.md @@ -4,13 +4,13 @@ Covers the **memory provider** category — plugins that persist knowledge acros A memory provider does two things: it reads relevant recall into context assembly, and it writes new knowledge worth persisting across sessions. This is a **distinct plugin category with its own protocol**, not a reuse of the context provider's `Contribute` RPC — memory-specific data (record type, scope, provenance, ratification status) stays first-class through a dedicated `Recall` RPC, and the kernel adapts results into `ContextSection`s before merging them into the assembled prompt. See [`protocol.md#recall-the-read-side`](protocol.md#recall-the-read-side). -The underlying storage mechanism — files, sqlite, a vector store, a remote service — is entirely a provider implementation detail. This category is backend-agnostic behind one interface, the same abstraction-over-backend move Terraform makes for state; see [`architecture.md`](../architecture.md#the-six-provider-categories). +The underlying storage mechanism — files, sqlite, a vector store, a remote service — is entirely a provider implementation detail. This category is backend-agnostic behind one interface, the same abstraction-over-backend move Terraform makes for state; see [`architecture.md`](../architecture.md#the-seven-provider-categories). The design draws on patterns seen across coding harnesses — automatic session memory, tiered recall, and inbox-style ratification — while fixing one specific piece, the record taxonomy ([`taxonomy.md`](taxonomy.md)), as a deliberate protocol-level choice rather than leaving it to each provider. ## Transport & lifecycle -Subprocess + gRPC via `hashicorp/go-plugin`, per [`architecture.md`](../architecture.md#transport) — the standard handshake applies uniformly across all six provider categories and isn't repeated here. +Subprocess + gRPC via `hashicorp/go-plugin`, per [`architecture.md`](../architecture.md#transport) — the standard handshake applies uniformly across all seven provider categories and isn't repeated here. A memory provider plugin exposes nine RPCs: `GetCapabilities`, `Configure`, `Recall`, `Record`, `UpdateRecord`, `DeleteRecord`, `ListRecords`, `GetRecord`, `Describe`. It MAY additionally implement `ApproveRecord`/`RejectRecord` (the optional ratification pattern, [`protocol.md#ratification-optional`](protocol.md#ratification-optional)) and `Render` ([`protocol.md#render`](protocol.md#render)). All eleven RPCs are unary — unlike the model provider's `StreamCompletion` or the tool provider's `Invoke`, nothing in this category streams. diff --git a/docs/specifications/memory/data-types.md b/docs/specifications/memory/data-types.md index 5531fe6..a4ee7bd 100644 --- a/docs/specifications/memory/data-types.md +++ b/docs/specifications/memory/data-types.md @@ -11,7 +11,11 @@ MemoryCapabilities { supported_scopes []MemoryScope // MUST — a provider MAY handle only a subset // (e.g. project-only) ratification_supported bool // MUST, default false — see protocol.md#ratification-optional - slash_commands []SlashCommandSpec // MAY — see frontend/README.md#slash-commands + slash_commands []common.v1.PromptExpansionSpec // MAY — static template-expansion + // commands only; see + // frontend/frontend-protocol.md#slash-commands. + // A direct-invoke command is declared by a + // slashcommand.v1 provider instead, never here config_schema ConfigSchema // MUST — decoded per configuration/blocks-reference.md supported_hook_points []common.v1.HookPoint // MAY be empty — which hook points this // provider subscribes DispatchHook to, diff --git a/docs/specifications/memory/protocol.md b/docs/specifications/memory/protocol.md index 1238834..bdbdaa6 100644 --- a/docs/specifications/memory/protocol.md +++ b/docs/specifications/memory/protocol.md @@ -6,7 +6,7 @@ The RPCs a memory provider plugin exposes. See [`README.md`](README.md#transport Returns a `MemoryCapabilities` value declaring what this provider supports — see [`data-types.md#memorycapabilities`](data-types.md#memorycapabilities) for the full shape. A provider MAY handle only a subset of the fixed `MemoryType`/`MemoryScope` taxonomies ([`taxonomy.md`](taxonomy.md)); it MUST declare exactly which subset via `supported_types`/`supported_scopes`. -The response MAY additionally include `slash_commands: []SlashCommandSpec` (declared once for the provider as a whole) and MUST include the provider's `ConfigSchema`, so the kernel knows what fields `Configure` expects before ever calling it — the reference tools ([`examples.md#write-triggers-reference-tools`](examples.md#write-triggers-reference-tools)) already cover the common `remember`/`forget`/`search` cases via the ordinary tool-provider path, so this is rarely needed in practice. +The response MAY additionally include `slash_commands: []common.v1.PromptExpansionSpec` (declared once for the provider as a whole) and MUST include the provider's `ConfigSchema`, so the kernel knows what fields `Configure` expects before ever calling it — the reference tools ([`examples.md#write-triggers-reference-tools`](examples.md#write-triggers-reference-tools)) already cover the common `remember`/`forget`/`search` cases via the ordinary tool-provider path, so this is rarely needed in practice. Each `PromptExpansionSpec` is a static template-expansion command only, with no way to invoke anything — the kernel expands `template` with the user's arguments and submits the result as an ordinary user message. A direct-invoke command is declared by a `slashcommand.v1` provider instead ([`../slashcommand/protocol.md`](../slashcommand/protocol.md)), never here. ## `Configure` diff --git a/docs/specifications/model/README.md b/docs/specifications/model/README.md index 4998970..1ddd179 100644 --- a/docs/specifications/model/README.md +++ b/docs/specifications/model/README.md @@ -1,6 +1,6 @@ # Model provider protocol -Covers the **model provider** category — an LLM vendor plugin (Anthropic, OpenAI, Gemini, etc.). In this system's Terraform-derived vocabulary the LLM vendor plugin is the closest analog to what Terraform itself calls a "provider" — it's the anchor spec category; the other five (`tool/`, `context/`, `memory/`, `frontend/`) follow the shape it establishes. +Covers the **model provider** category — an LLM vendor plugin (Anthropic, OpenAI, Gemini, etc.). In this system's Terraform-derived vocabulary the LLM vendor plugin is the closest analog to what Terraform itself calls a "provider" — it's the anchor spec category; the other six (`tool/`, `context/`, `memory/`, `frontend/`, `widget/`, `slashcommand/`) follow the shape it establishes. Real-world LLM vendors (Anthropic, OpenAI, Google Gemini, Mistral, Cohere, xAI, Ollama, and others) diverge in significant ways — reasoning control, caching mechanics, tool-call wire shape — and this category's data types are shaped to accommodate that heterogeneity rather than assuming one vendor's design is universal. @@ -8,7 +8,7 @@ See [`architecture.md`](../architecture.md) for the surrounding system (transpor ## Transport & lifecycle -Subprocess + gRPC via `hashicorp/go-plugin`, per [`architecture.md`](../architecture.md#transport). Standard handshake (magic cookie, protocol version negotiation) applies uniformly across all six provider categories and isn't repeated per category. +Subprocess + gRPC via `hashicorp/go-plugin`, per [`architecture.md`](../architecture.md#transport). Standard handshake (magic cookie, protocol version negotiation) applies uniformly across all seven provider categories and isn't repeated per category. A model provider plugin exposes five RPCs: `GetCapabilities`, `Configure`, `StreamCompletion`, `CountTokens`, `Describe`. It MAY additionally implement `Render` (see [`protocol.md#render`](protocol.md#render)). diff --git a/docs/specifications/model/data-types.md b/docs/specifications/model/data-types.md index ec0db07..f2dee8a 100644 --- a/docs/specifications/model/data-types.md +++ b/docs/specifications/model/data-types.md @@ -195,7 +195,7 @@ StreamCompletionRequest { Ordering is **chain order**: the same order `context/protocol.md`'s `ContextRequest.prior_sections`/`Contribute` response chain uses, where each provider appends its own section after the sections it received. This is the tools → system → static-project-context → conversation-tail prefix ordering `context/data-types.md#ordering--chaining` establishes for prompt-cache reuse. -`assembled_context` is distinct from `messages`: it is system-level/preamble content, never a conversational turn — which is exactly why it's a separate field rather than a synthetic message. **`content.v1.Role` deliberately has no `SYSTEM` value** ([`data-types.md`](#canonical-message--content-block-schema)'s `Message`/`Role` definitions; `content.proto`'s `Role` comment): system-level content is always assembled as a `ContextSection` chain, never carried as a message with a role. Each model-provider adapter maps `assembled_context` to its own vendor's system/preamble mechanism — a top-level `system` string, a leading system-role message, or whatever else that vendor's API expects — and that mapping is adapter-internal, not part of this wire contract. +`assembled_context` is distinct from `messages`: it is system-level/preamble content, never a conversational turn — which is exactly why it's a separate field rather than a synthetic message. **`content.v1.Role` deliberately has no `SYSTEM` value** ([`data-types.md`](#canonical-message--content-block-schema)'s `Message`/`Role` definitions; `content/v1/types.proto`'s `Role` comment): system-level content is always assembled as a `ContextSection` chain, never carried as a message with a role. Each model-provider adapter maps `assembled_context` to its own vendor's system/preamble mechanism — a top-level `system` string, a leading system-role message, or whatever else that vendor's API expects — and that mapping is adapter-internal, not part of this wire contract. ### `call_context` @@ -217,7 +217,7 @@ CacheBreakpoint { `cache_breakpoints` is meaningful only when the target model's `CachingSpec.mode == CACHING_MODE_EXPLICIT_MARKERS`; a model-provider adapter targeting a model whose `CachingSpec.mode` is `CACHING_MODE_IMPLICIT_AUTOMATIC` or `CACHING_MODE_NONE` MUST ignore this field rather than error on it. The adapter maps each breakpoint to its vendor's own cache-control mechanism (e.g. an Anthropic `cache_control` block on the targeted content). -**Breakpoint placement is a kernel decision, not the plugin's.** The kernel knows each `assembled_context` section's `Stability` (`content.proto`'s `Stability` enum: `STABILITY_STATIC` vs. `STABILITY_DYNAMIC`) and each message's position in the conversation, so it places breakpoints at natural stable-prefix boundaries — the same tools → system → static-project-context → conversation-tail ordering that governs `assembled_context`'s own chain order. In practice this means: a breakpoint after `after_tools` when the tool declaration list is stable turn to turn, and a breakpoint after `after_assembled_context` when the whole chain's leading sections are `STABILITY_STATIC` — since that's usually the longest stable prefix a vendor's prompt cache can actually reuse. A plugin never invents its own placement; it only translates the breakpoints the kernel already decided into vendor-native markers. +**Breakpoint placement is a kernel decision, not the plugin's.** The kernel knows each `assembled_context` section's `Stability` (`content/v1/types.proto`'s `Stability` enum: `STABILITY_STATIC` vs. `STABILITY_DYNAMIC`) and each message's position in the conversation, so it places breakpoints at natural stable-prefix boundaries — the same tools → system → static-project-context → conversation-tail ordering that governs `assembled_context`'s own chain order. In practice this means: a breakpoint after `after_tools` when the tool declaration list is stable turn to turn, and a breakpoint after `after_assembled_context` when the whole chain's leading sections are `STABILITY_STATIC` — since that's usually the longest stable prefix a vendor's prompt cache can actually reuse. A plugin never invents its own placement; it only translates the breakpoints the kernel already decided into vendor-native markers. ## `GenerationParams` @@ -251,11 +251,11 @@ ToolChoiceMode = enum { UNSPECIFIED, AUTO, ANY, NONE, SPECIFIC } `Capabilities` (`GetCapabilities`'s response payload) additionally carries `supported_hook_points: []common.v1.HookPoint` — which of the eight dispatchable hook points ([`agent-loop/hook-dispatch.md`](../agent-loop/hook-dispatch.md)) this plugin can serve via `HookSubscriberService.DispatchHook`. The kernel MUST reject an `agent.hcl` `hook{}` block naming a point absent from this list, at config-load time. -This is typed as `common.v1.HookPoint`, not `hook.v1.HookPoint`: `hook.proto` imports `model.proto` (for `ModelRef`/`Usage` on its pre-model-call/post-model-response hook payloads), so `model.proto` importing `hook.proto` for this field would be a cyclic file dependency `buf build` rejects outright. `HookPoint` itself is declared in `common.v1` for exactly this reason, alongside `CallContext` and `ProducerRef`. +This is typed as `common.v1.HookPoint`, not `hook.v1.HookPoint`: `hook/v1/events.proto` imports `model/v1/types.proto` (for `ModelRef`/`Usage` on its pre-model-call/post-model-response hook payloads), so `model/v1/types.proto` importing anything from `hook.v1` for this field would be a cyclic file dependency `buf build` rejects outright. `HookPoint` itself is declared in `common.v1` for exactly this reason, alongside `CallContext` and `ProducerRef`. ## `Describe` -`ModelService` gains a `Describe(DescribeRequest) -> DescribeResponse { producer: common.v1.ProducerRef }` RPC, identical in shape across all six category protocols in this protocol revision. It reports this plugin build's own identity — `{name, version, source, category, protocol_version}` — directly from the running process. This matters specifically for a `dev_overrides` binary ([`configuration/settings-and-global.md#dev_overrides`](../configuration/settings-and-global.md#dev_overrides)), which bypasses the registry/lock-file resolution path entirely and so has no `provider "" { ... }` lock entry for the kernel to read identity from; see [`configuration/lock-file.md`](../configuration/lock-file.md#dev_overrides-and-identity-without-a-lock-entry)'s `dev_overrides` note for the canonical explanation. +`ModelService` gains a `Describe(DescribeRequest) -> DescribeResponse { producer: common.v1.ProducerRef }` RPC, identical in shape across all seven category protocols in this protocol revision. It reports this plugin build's own identity — `{name, version, source, category, protocol_version}` — directly from the running process. This matters specifically for a `dev_overrides` binary ([`configuration/settings-and-global.md#dev_overrides`](../configuration/settings-and-global.md#dev_overrides)), which bypasses the registry/lock-file resolution path entirely and so has no `provider "" { ... }` lock entry for the kernel to read identity from; see [`configuration/lock-file.md`](../configuration/lock-file.md#dev_overrides-and-identity-without-a-lock-entry)'s `dev_overrides` note for the canonical explanation. ## Tool schema diff --git a/docs/specifications/model/protocol.md b/docs/specifications/model/protocol.md index 0eff047..5826c23 100644 --- a/docs/specifications/model/protocol.md +++ b/docs/specifications/model/protocol.md @@ -6,7 +6,7 @@ The six RPCs a model provider plugin exposes. See [`README.md`](README.md#transp Returns a `Capabilities` value with one `ModelSpec` per model the plugin can serve. This MUST be re-queryable cheaply (the kernel may call it often — e.g. before every routing decision) and MUST NOT require network calls to the vendor if avoidable; a plugin SHOULD ship its model list built in and refresh it lazily/periodically rather than blocking on a live API call per invocation. -The response MAY additionally include `slash_commands: []SlashCommandSpec` (declared once for the provider as a whole, not per model) and MUST include the provider's `ConfigSchema`, so the kernel knows what fields `Configure` expects before ever calling it. See [`data-types.md`](data-types.md#modelspec) for the full `ModelSpec` shape. +The response MAY additionally include `slash_commands: []common.v1.PromptExpansionSpec` (declared once for the provider as a whole, not per model) and MUST include the provider's `ConfigSchema`, so the kernel knows what fields `Configure` expects before ever calling it. Each `PromptExpansionSpec` is a static template-expansion command only — the kernel expands `template` with the user's arguments and submits the result as an ordinary user message, never executing anything. A direct-invoke command that runs one of this provider's own operations is declared by a `slashcommand.v1` provider instead ([`../slashcommand/protocol.md`](../slashcommand/protocol.md)), never here. See [`data-types.md`](data-types.md#modelspec) for the full `ModelSpec` shape. The response also carries `supported_hook_points: []common.v1.HookPoint` ([`data-types.md#capabilitiessupported_hook_points`](data-types.md#capabilitiessupported_hook_points)) — declared once for the provider as a whole, not per model, mirroring `slash_commands`. The kernel MUST reject an `agent.hcl` `hook{}` block for this plugin naming a point absent from this list, at config-load time, rather than discovering the mismatch only when `HookSubscriberService.DispatchHook` is actually called. @@ -80,4 +80,4 @@ Model providers MAY implement `Render` per the general Emit→Render→Paint pip Describe(DescribeRequest{}) -> DescribeResponse{ producer: common.v1.ProducerRef } ``` -Reports this plugin build's own identity — `{name, version, source, category, protocol_version}` — directly from the running process, the same shape every one of the six category protocols gains in this protocol revision. This exists chiefly for a [`configuration/settings-and-global.md#dev_overrides`](../configuration/settings-and-global.md#dev_overrides) binary, which has no `provider "" { ... }` lock-file entry to read identity from; see [`configuration/lock-file.md`](../configuration/lock-file.md#dev_overrides-and-identity-without-a-lock-entry)'s `dev_overrides` note for the canonical explanation. +Reports this plugin build's own identity — `{name, version, source, category, protocol_version}` — directly from the running process, the same shape every one of the seven category protocols gains in this protocol revision. This exists chiefly for a [`configuration/settings-and-global.md#dev_overrides`](../configuration/settings-and-global.md#dev_overrides) binary, which has no `provider "" { ... }` lock-file entry to read identity from; see [`configuration/lock-file.md`](../configuration/lock-file.md#dev_overrides-and-identity-without-a-lock-entry)'s `dev_overrides` note for the canonical explanation. diff --git a/docs/specifications/observability.md b/docs/specifications/observability.md new file mode 100644 index 0000000..72d8dff --- /dev/null +++ b/docs/specifications/observability.md @@ -0,0 +1,54 @@ +# Observability + +The fourth kernel-owned (non-plugin) spec, alongside [`kernel-callbacks.md`](kernel-callbacks.md), [`state-backend.md`](state-backend.md), and [`event-bus.md`](event-bus.md). It covers how a plugin's own traces, metrics, and logs reach the operator's configured collector via `KernelCallbackService.ExportSpans`/`.RecordMetrics`/`.GetTelemetryConfig` ([`kernel-callbacks.md#exportspans`](kernel-callbacks.md#exportspans), [`kernel-callbacks.md#recordmetrics`](kernel-callbacks.md#recordmetrics), [`kernel-callbacks.md#gettelemetryconfig`](kernel-callbacks.md#gettelemetryconfig)) — the wire shape lives there; this document covers the relay model, why it exists, and the one place tracing and metrics deliberately diverge. `Log`'s equivalent relay ([`kernel-callbacks.md#log`](kernel-callbacks.md#log)) already existed before this document and is not repeated here. + +## The relay model + +A plugin subprocess's spans and metrics are relayed through the kernel to a single, kernel-configured collector, rather than each plugin process exporting OTLP directly to a collector of its own. Concretely: a plugin builds an ordinary OTel SDK pipeline (`tracer.Start(...)`, real spans, real instruments), but the SDK's exporter is one that ships batches to the kernel via `ExportSpans`/`RecordMetrics` instead of opening its own network connection to a collector. The kernel forwards `ExportSpans`' spans to the collector essentially unchanged (see "Span relay is transparent" below); `RecordMetrics` is handled differently (see "The tracing/metrics asymmetry"). + +**This reverses an earlier design.** A span-funnel-through-the-kernel RPC was previously considered and rejected in favor of direct per-process OTLP export, on the reasoning that trace nesting across the plugin boundary already worked via ordinary W3C `traceparent` propagation over the kernel-callback channel's gRPC stats handlers (`internal/telemetry/grpchooks.go`'s `ClientHandler`/`ServerHandler`), so a funnel seemed like unneeded indirection. This revision reverses that call, for two reasons that outweigh the extra serialization hop: + +- **A plugin subprocess should not need network egress or collector credentials to be observable.** Direct export means every plugin process needs outbound access to wherever the collector lives, and (for an authenticated collector) its own copy of whatever credential that requires. Relaying through the kernel means only the kernel needs that access and that credential — a plugin subprocess's network footprint stays limited to the local gRPC connection it already has to the kernel. +- **The kernel becomes the single place sampling and export configuration lives.** `GetTelemetryConfig` (`kernel-callbacks.md#gettelemetryconfig`) already makes the kernel the authority a plugin asks "is tracing on, at what ratio" — relaying the actual span data through the same connection means there is exactly one collector endpoint, one sampling ratio, and one export cadence to reason about operationally, not one per plugin process plus the kernel's own. + +**Trace-context propagation across the plugin boundary is unchanged by this reversal.** `traceparent` still crosses the gRPC boundary via the otelgrpc stats handlers exactly as it always has — relaying a span's *export* through the kernel is a transport decision about where finished spans go, not a second, competing propagation mechanism for how an in-flight call's trace context gets from the kernel to a plugin or back. `.claude/rules/logging-telemetry.md`'s ban on hand-rolled trace-context propagation governs that separate concern and is untouched here. + +### Span relay is transparent + +The kernel cannot feed a relayed span into its own `sdktrace` pipeline the way it instruments its own code: `sdktrace.ReadOnlySpan` carries an unexported method and is unimplementable outside the SDK itself, and re-creating a span via the kernel's own `tracer.Start` would assign that span a fresh `trace_id`/`span_id`, silently severing it from the plugin-internal parent/child relationships it already had. So the relay bypasses the kernel's SDK pipeline entirely and speaks OTLP directly: `go.opentelemetry.io/otel/exporters/otlp/otlptrace.Client`'s `UploadTraces` takes already-built `tracepb.ResourceSpans` and ships them, unmodified, to the configured collector. The kernel MUST NOT alter a relayed span's `trace_id`, `span_id`, `parent_span_id`, or timestamps before forwarding it — the one thing it adds is producer attribution on the exported resource, server-derived from the callback connection exactly as `Emit`/`Log` attribute their own callers, never read from the span's own fields (there is no field on `trace.v1.Span` for a plugin to declare its own identity into, by the same anti-spoof reasoning `kernel-callbacks.md#the-callback-channel` already applies everywhere else on this service). + +## The tracing/metrics asymmetry + +`RecordMetrics` is deliberately **not** a transparent relay, and this is a permanent design choice, not a gap to "fix" toward symmetry with `ExportSpans` later: + +- There is no metrics equivalent of `otlptrace.Client` to bypass through — `otlpmetricgrpc` exposes no comparable already-batched-protobuf uploader interface, so a transparent relay isn't even the path of least resistance here the way it is for spans. +- More importantly, `.claude/rules/logging-telemetry.md`'s cardinality rule is non-negotiable: an unbounded identifier (a session ID, a turn index, a request ID) MUST never become a metric attribute, on pain of silently breaking a metrics backend's cardinality budget. A plugin-supplied `metric.v1.MetricRecord.attributes` map is, by construction, an open set the kernel cannot pre-validate against any fixed vocabulary — a transparent relay would hand an arbitrary third-party plugin exactly the ability this rule exists to prevent. + +Instead, the kernel treats a `RecordMetrics` call as an observation against its **own** instruments: it lazily creates (or reuses) an instrument named `plugin.{category}.{name}.{metric_name}` — the same server-derived producer identity used to build an event-bus topic — on its own `MeterProvider`, and records the observation there. Attribute keys beyond a bounded per-instrument set are dropped, with a throttled `WARN` log identifying which keys were dropped and how many times, so an over-cardinality plugin is visible in the logs rather than silently truncated with no signal at all. Spans keep unbounded attributes (a span's attributes go on the span only, per the existing cardinality rule's own carve-out for span-only unbounded data); metrics never do, on any code path, plugin-sourced or kernel-native. + +## `GetTelemetryConfig` caching + +A plugin SHOULD call `GetTelemetryConfig` once at process startup (typically from the same bootstrap call that wires its OTel SDK pipeline) and cache the result for its process lifetime — `traces_enabled`/`metrics_enabled`/`logs_enabled`/`log_level`/`sampling_ratio` are operator configuration ([`configuration/blocks-reference.md#observability`](configuration/blocks-reference.md#observability)), not something that changes mid-process. A plugin's `TracingEnabled()`/log-level check is expected to be a cached field read, never a per-call RPC round-trip — see [`kernel-callbacks.md#gettelemetryconfig`](kernel-callbacks.md#gettelemetryconfig). + +## Telemetry never replays and never persists + +[`state-backend.md`](state-backend.md) already establishes that telemetry is the one thing that must *not* replay faithfully: trace/span IDs are non-deterministic by construction (`crypto/rand`-backed), so there is no `trace_id`/`span_id` column anywhere in the schema, and replay MUST select a no-op telemetry driver unconditionally rather than attempt to reproduce identical IDs. This document restates that MUST because it now governs a second thing besides the kernel's own native instrumentation: a relayed plugin span or metric observation MUST NOT be written to `events`, `cost_ledger`, or `plan_items` either, and a replay session MUST NOT call `ExportSpans`/`RecordMetrics` against a real collector on the plugin's behalf — the same no-op-driver rule that already governs the kernel's own spans governs anything relayed through it. + +Relatedly, telemetry never owns a number it didn't originate: `RecordMetrics`/`ExportSpans` observe usage/cost/token figures the kernel's own cost-ledger write already computed ([`model/protocol.md#cost-computation`](model/protocol.md#cost-computation)) — a plugin's own span attributes MAY carry those same figures for tracing convenience, but the kernel never recomputes or treats a telemetry-carried figure as authoritative over the persisted one. + +## Required vs. optional support + +| Capability | Level | Notes | +|---|---|---| +| Spans relayed via `ExportSpans` reach the operator's configured collector unmodified | MUST | "Span relay is transparent" | +| Kernel alters a relayed span's identity/timestamps | MUST NOT | "Span relay is transparent" | +| `RecordMetrics` observations recorded against kernel-owned instruments, not relayed as OTLP | MUST | "The tracing/metrics asymmetry" | +| Metric attribute keys bounded per instrument, excess dropped with a throttled `WARN` | MUST | "The tracing/metrics asymmetry" | +| A plugin caches `GetTelemetryConfig` for its process lifetime rather than polling | SHOULD | "`GetTelemetryConfig` caching" | +| Replay selects the no-op telemetry driver unconditionally | MUST | "Telemetry never replays and never persists" | +| A relayed span/metric persisted to `events`/`cost_ledger`/`plan_items` | MUST NOT | "Telemetry never replays and never persists" | + +## Open questions + +- Whether `RecordMetrics`' per-instrument bounded-attribute-key set should be operator-configurable (a fixed default vs. an `agent.hcl`-declared allowlist per metric name) — shipped with a fixed default in this revision; a config surface is a plausible follow-up once real plugin metric usage shows what's actually needed. +- Whether a future revision should let a plugin request higher-than-configured sampling for a specific span (a "force-sample this trace" escape hatch) — no candidate use case has surfaced yet. diff --git a/docs/specifications/slashcommand/README.md b/docs/specifications/slashcommand/README.md new file mode 100644 index 0000000..14cba26 --- /dev/null +++ b/docs/specifications/slashcommand/README.md @@ -0,0 +1,22 @@ +# Slash-command provider protocol + +Covers the **slash-command provider** category — a plugin that declares and directly executes one or more slash commands in its own right (`/deploy`, `/release-notes`, ...), rather than merely expanding a static prompt template. Sibling to [`tool/`](../tool/README.md) (tool provider) in structure and gating mechanics: a direct-invoke slash command is a tool-shaped operation — it declares an `input_schema`, a `kind`/`risk` classification, and a `ConcurrencySpec`, and it flows through the identical plan/apply gate a tool call does. This category exists so a plugin can declare and execute a command without also being a tool provider and aliasing into one of its own tool operations. + +This category depends directly on [`tool/`](../tool/README.md): `SlashCommandSpec.kind`/`risk`/`concurrency` are `pluggableharness.tool.v1.ToolKind`/`RiskClass`/`ConcurrencySpec` reused directly, not redeclared, and `SlashCommandEvent.result`/`error`/`OutputChunk.stream` reuse `tool.v1.ToolResult`/`ToolError`/`OutputStream` the same way — see [`data-types.md#reused-toolv1-types`](data-types.md#reused-toolv1-types) for the full list. `SlashCommandSpec.input_schema` uses the same common JSON-Schema subset [`model/data-types.md#tool-schema`](../model/data-types.md#tool-schema) defines for tool authors, one wire type (`pluggableharness.schema.v1.Schema`) shared across every category that declares one. + +**Not to be confused with a prompt-expansion slash command** — a purely static `/name` → template-string expansion that never executes anything and costs an ordinary model turn once expanded. That variant is `pluggableharness.common.v1.PromptExpansionSpec`, declared directly in any of the other six categories' own capability response (e.g. [`tool/protocol.md#getschema`](../tool/protocol.md#getschema)'s `slash_commands` field), and has zero dependency on this category's vocabulary. See [`data-types.md#slashcommandspec-vs-promptexpansionspec`](data-types.md#slashcommandspec-vs-promptexpansionspec) for the full distinction. + +## Transport & lifecycle + +Subprocess + gRPC via `hashicorp/go-plugin`, per [`architecture.md`](../architecture.md#transport). Standard handshake (magic cookie, protocol version negotiation) applies uniformly across all seven provider categories, per [`architecture.md`](../architecture.md#the-seven-provider-categories), and isn't repeated per category. + +A slash-command provider plugin exposes four RPCs: `GetCapabilities`, `Configure`, `Invoke`, `Describe`. It MAY additionally implement `Render` (see [`protocol.md#render`](protocol.md#render)) and `Preview` (see [`protocol.md#preview`](protocol.md#preview)). + +**`Invoke` is server-streaming**, identical in shape and cancellation semantics to [`tool/protocol.md#invoke`](../tool/protocol.md#invoke) — a direct-invoke command like `/deploy` may need to stream live output exactly as an `exec` tool call does, and the two RPCs share the identical streaming-plus-cancellation contract for the identical reason: none of the underlying primitives (process exec, HTTP call, file I/O) need mid-call client input on the same call. **Cancellation follows the tool-provider pattern exactly**: the kernel cancels/closes the gRPC stream; it is not a distinct RPC or a sentinel event the plugin must invent. Plugin authors MUST treat stream cancellation as a normal, expected event — kill the child process, release file handles/sockets, discard buffers — never as an error condition. + +## Category structure + +- [`protocol.md`](protocol.md) — the RPCs: `GetCapabilities`, `Configure`, `Invoke`, `Render`, `Preview`, `Describe`. +- [`data-types.md`](data-types.md) — `SlashCommandSpec`, `SlashCommandCall`, `SlashCommandEvent`, and the `pluggableharness.tool.v1` types this category reuses rather than redeclares. +- [`examples.md`](examples.md) — a worked `agent.hcl` provider block, the real proto wire definitions, and a full `Invoke` event sequence including cancellation. +- [`conformance.md`](conformance.md) — the error taxonomy (reused from [`tool/`](../tool/README.md)) and the MUST/SHOULD/MAY summary matrix, plus genuinely open questions. diff --git a/docs/specifications/slashcommand/conformance.md b/docs/specifications/slashcommand/conformance.md new file mode 100644 index 0000000..906994c --- /dev/null +++ b/docs/specifications/slashcommand/conformance.md @@ -0,0 +1,42 @@ +# Slash-command provider — conformance + +## Error taxonomy + +This category defines no `SlashCommandError`/`SlashCommandErrorCategory` of its own — `SlashCommandEvent.error` is a `pluggableharness.tool.v1.ToolError` reused verbatim, per [`data-types.md#reused-toolv1-types`](data-types.md#reused-toolv1-types), so its failure taxonomy is [`tool/conformance.md#error-taxonomy`](../tool/conformance.md#error-taxonomy)'s `ToolErrorCategory`, unmodified: `invalid_arguments`, `not_found`, `permission_denied`, `execution_failed`, `timeout`, `concurrency_conflict`, `cancelled`, `process_crashed`, `unknown`. A plugin MUST classify every `Invoke` failure using this same enum, MUST NOT collapse them into one generic error, and MUST NOT invent a parallel category — a direct-invoke command's failure modes are the same domain as a tool call's (no vendor-specific concepts like `rate_limited` apply here any more than they do to a tool operation). + +`process_crashed` carries the identical kernel-synthesized-only rule [`tool/conformance.md#error-taxonomy`](../tool/conformance.md#error-taxonomy) documents: a plugin never constructs one itself, only the kernel does, from a transport-level failure. On the wire it maps to `codes.Unavailable`, per `.claude/rules/grpc.md`'s canonical error-taxonomy-to-`codes` mapping table. + +The kernel's expected reaction per category is the same table [`tool/conformance.md#error-taxonomy`](../tool/conformance.md#error-taxonomy) already specifies — this document does not duplicate it. One addition specific to this category: because `SlashCommandSpec` declares no `output_schema` (see [`data-types.md#slashcommandspec`](data-types.md#slashcommandspec)), the `unknown`-category "malformed payload" trigger [`tool/protocol.md#invoke`](../tool/protocol.md#invoke) describes for a `ToolResult` failing `output_schema` validation has no equivalent here — a `SlashCommandEvent.result.payload` is accepted without a schema to validate it against, so it can never itself be the cause of an `unknown`-category `ToolError`. + +### The `idempotent` / retry interaction + +Reused verbatim from [`tool/conformance.md#the-idempotent--retry-interaction`](../tool/conformance.md#the-idempotent--retry-interaction), with `SlashCommandSpec.idempotent` standing in for `ToolSchema.idempotent`: the kernel MAY auto-retry a retryable `ToolError` for a `TOOL_KIND_RESOURCE` command — without first surfacing it to the model as a failed call — only when that command's `idempotent` is `true`. `TOOL_KIND_DATA_SOURCE` commands are exempt from this gate entirely. `TOOL_KIND_INTERACTIVE` calls are never auto-retried. + +## Required vs. optional support — summary matrix + +| Capability | Level | Notes | +|---|---|---| +| `GetCapabilities` / `Configure` / `Invoke` RPCs | MUST | the whole protocol surface | +| `Describe` RPC | MUST | [`protocol.md#describe`](protocol.md#describe); needed for `dev_overrides` plugin identity per [`configuration/lock-file.md`](../configuration/lock-file.md#dev_overrides-and-identity-without-a-lock-entry) | +| Streaming RPC shape for `Invoke` | MUST | see [`README.md`](README.md#transport--lifecycle) / [`protocol.md#invoke`](protocol.md#invoke) — applies even to non-streaming commands | +| `SlashCommandCall.call_context` | MUST be set by the kernel, every `Invoke` call | [`protocol.md#invoke`](protocol.md#invoke) | +| `input_schema` in the common JSON-Schema subset | MUST | [`data-types.md#slashcommandspec`](data-types.md#slashcommandspec) | +| `kind` (resource / data_source / interactive) | MUST, per command | reused from `tool.v1.ToolKind`; drives the plan/apply gate identically to a tool operation | +| `risk` classification | MUST, per command | reused from `tool.v1.RiskClass`; `read_only` for `data_source` and `interactive` alike | +| `ConcurrencySpec.safe` | MUST, per command except `interactive` | absent/unset MUST be treated as `false`; MUST NOT be declared for `interactive` | +| `ConcurrencySpec.key_fields` | MAY, per command | only meaningful under `safe: true` | +| `default_timeout` | SHOULD, per command | absent means the kernel's global default applies | +| `idempotent` | MUST, per command | gates kernel auto-retry, see above | +| `supported_hook_points` | MAY | empty means this provider subscribes no `hook{}` blocks | +| `exit_status` event | MUST for process-backed commands; MUST NOT otherwise | | +| `output_chunk` / `progress` / `partial_result` events | MAY | only for commands with `streaming: true` | +| Structured `ToolError` taxonomy, including `process_crashed` | MUST | reused from [`tool/conformance.md#error-taxonomy`](../tool/conformance.md#error-taxonomy) | +| `output_schema` | Not applicable | `SlashCommandSpec` declares none — a direct-invoke command is never model-callable, see [`data-types.md#slashcommandspec`](data-types.md#slashcommandspec) | +| Best-effort partial-mutation report on cancellation | MUST, for `resource` commands | see [`protocol.md#invoke`](protocol.md#invoke) | +| `Render` | MAY | generic fallback exists; `RenderRequest.schema_version` per [`frontend/render-tree.md#schema-versioning`](../frontend/render-tree.md#schema-versioning) | +| `Preview` | MAY | [`protocol.md#preview`](protocol.md#preview); kernel MUST fall back to raw `arguments` when absent; MUST NOT mutate anything when implemented | + +## Open questions + +- **Combining `ToolService` and `SlashCommandService` in one plugin process.** A provider that wants a direct-invoke shortcut into one of its own tool operations — rather than duplicating that operation's logic inside a separate `slashcommand.v1`-only plugin — implements both `ToolService` and `SlashCommandService` on the same subprocess connection. `hashicorp/go-plugin` supports this natively (multiple gRPC services muxed over one broker connection), and it's not a novel pattern in this protocol: `HookSubscriberService` already establishes the identical precedent for any category's plugin optionally implementing a second service alongside its primary one, per [`agent-loop/hook-dispatch.md#wire-contract--pluggableharnesshookv1`](../agent-loop/hook-dispatch.md#wire-contract--pluggableharnesshookv1). This combination is expected to be common — most first-party direct-invoke commands will likely live inside an existing tool provider rather than a standalone `slashcommand.v1`-only plugin — but the exact `agent.hcl`/lock-file mechanics of "one provider block, two category services, potentially two independent `GetCapabilities`-equivalent calls, one shared `Configure`" are genuinely unresolved here and flagged as open rather than assumed settled. +- **Whether a name collision check across `SlashCommandSpec.name` and `PromptExpansionSpec.name` is required.** [`data-types.md#slashcommandspec-vs-promptexpansionspec`](data-types.md#slashcommandspec-vs-promptexpansionspec) states the two occupy independent namespaces at the protocol level, but from a user's point of view both surface as `/name` in the same frontend command palette — whether the config-load-time collision check each namespace already enforces on its own should be widened to cover both specs together (so `/deploy` can't simultaneously be a direct-invoke command from one provider and a prompt-expansion command from another) is left to a future revision of that check, not decided here. diff --git a/docs/specifications/slashcommand/data-types.md b/docs/specifications/slashcommand/data-types.md new file mode 100644 index 0000000..59a999b --- /dev/null +++ b/docs/specifications/slashcommand/data-types.md @@ -0,0 +1,93 @@ +# Slash-command provider — data types + +## `SlashCommandSpec` + +Declares one directly-invocable command this provider exposes — a tool-shaped operation invoked via this same provider's own `SlashCommandService.Invoke`, never by naming another provider's tool operation. + +```protobuf +SlashCommandSpec { + name string // MUST — the command's name, without the leading "/". MUST be unique + // across every direct-invoke command declared by every provider in + // the session — a name collision at config-load time is a hard error. + description string // MUST — shown in the frontend's hotkey_hints region and wherever + // else the frontend surfaces available commands + input_schema JSONSchema // MUST — common subset per model/data-types.md#tool-schema; + // describes the shape of SlashCommandCall.arguments + kind tool.v1.ToolKind // MUST — reused verbatim, see "Reused tool.v1 types" below + risk tool.v1.RiskClass // MUST — reused verbatim + concurrency tool.v1.ConcurrencySpec // MUST, except for kind == interactive — reused verbatim + streaming bool // MUST — true if Invoke may emit intermediate events (output_chunk, + // progress, partial_result) before the terminal event; false if + // Invoke always emits exactly one terminal event with no lead-up + default_timeout Duration? // SHOULD — the deadline the kernel applies to Invoke for this + // command absent an agent.hcl override; omitted means the kernel's + // own global default applies instead + idempotent bool // MUST — true iff re-running this command with identical arguments + // cannot produce a different end state than running it once; see + // tool/conformance.md#the-idempotent--retry-interaction + // No output_schema: unlike a tool operation, a direct-invoke command is never presented to the + // model as a callable tool — it dispatches without a model turn — so there is no LLM-facing + // structured-output contract to validate its result against. +} +``` + +`kind`/`risk`/`concurrency` carry the identical MUST-level rules [`tool/protocol.md#getschema`](../tool/protocol.md#getschema) and [`tool/data-types.md`](../tool/data-types.md) define for `ToolSchema`'s same-named fields — see [`protocol.md#getcapabilities`](protocol.md#getcapabilities) for the full cross-reference. This is not incidental convergence: a direct-invoke command flows through the identical plan/apply gate a tool call does ([`pluggableharness.plan.v1.PlanItem`](../agent-loop/plan-apply-gate.md#plan-construction-and-policy-evaluation)), so it needs the identical classification vocabulary, not a parallel copy of it. + +## `SlashCommandCall` / `SlashCommandEvent` + +```protobuf +SlashCommandCall { + id string // MUST — kernel-assigned, echoed in every emitted event for correlation + name string // MUST — matches a SlashCommandSpec.name from this provider's + // GetCapabilities response + arguments JSON // MUST — already-parsed JSON conforming to that SlashCommandSpec's + // input_schema + call_context CallContext // MUST be set by the kernel — pluggableharness.common.v1.CallContext. + // Carries session_id/turn_id, echoed by the plugin on its own + // KernelCallbackService.Emit/Log calls for attribution, and + // working_directory — the session's cwd at call time. +} + +SlashCommandEvent = oneof { + output_chunk { stream: tool.v1.OutputStream, data: bytes } + progress { message: string, fraction_complete: float? } + partial_result { payload: JSON } // incremental structured output, e.g. progress lines as emitted + exit_status { exit_code: int, signal: string? } // process-backed commands only + result tool.v1.ToolResult // terminal, success — reused verbatim + error tool.v1.ToolError // terminal, failure — reused verbatim, see + // conformance.md#error-taxonomy +} +``` + +`SlashCommandEvent` is structurally identical to [`tool/data-types.md#toolcall--toolevent--toolresult`](../tool/data-types.md#toolcall--toolevent--toolresult)'s `ToolEvent`, and the same streaming contract applies verbatim: `output_chunk`, `progress`, and `partial_result` MAY each appear zero or more times before the stream's terminal event; `exit_status` MAY appear at most once, and only for a command whose implementation is process-backed; exactly one of `result`/`error` MUST close the stream; `output_chunk` ordering within one stream MUST be preserved. See [`protocol.md#invoke`](protocol.md#invoke) for the full ordering and cancellation semantics — this document does not restate them. + +On the wire, `SlashCommandCall`/`SlashCommandEvent` are wrapped in thin per-RPC envelope messages (`InvokeRequest { call = 1; }`, `InvokeResponse { event = 1; }`), the same pattern [`tool/data-types.md`](../tool/data-types.md#toolcall--toolevent--toolresult) uses — see [`examples.md`](examples.md#the-wire-protocol) for the full message definitions. + +## Reused `tool.v1` types + +This category declares no parallel copy of any of the following — each is the literal `pluggableharness.tool.v1` message or enum, imported and reused as-is: + +| Type | Reused as | Defined in | +|---|---|---| +| `ToolKind` | `SlashCommandSpec.kind` | [`tool/protocol.md#getschema`](../tool/protocol.md#getschema) | +| `RiskClass` | `SlashCommandSpec.risk` | [`tool/data-types.md#riskclass`](../tool/data-types.md#riskclass) | +| `ConcurrencySpec` | `SlashCommandSpec.concurrency` | [`tool/data-types.md#concurrencyspec`](../tool/data-types.md#concurrencyspec) | +| `ToolResult` | `SlashCommandEvent.result` | [`tool/data-types.md#toolcall--toolevent--toolresult`](../tool/data-types.md#toolcall--toolevent--toolresult) | +| `ToolError` | `SlashCommandEvent.error` | [`tool/conformance.md#error-taxonomy`](../tool/conformance.md#error-taxonomy) | +| `OutputStream` | `SlashCommandEvent.OutputChunk.stream` | [`tool/examples.md#the-wire-protocol`](../tool/examples.md#the-wire-protocol) | + +A plugin MUST NOT redeclare any of these types under `pluggableharness.slashcommand.v1`; a kernel-side or plugin-side consumer decodes them exactly as it would decode the identically-named `tool.v1` message elsewhere in the system. This is a deliberate consequence of `kind`/`risk`/`concurrency`/results/errors meaning the same thing regardless of which of the two categories produced the call — see [`README.md`](README.md) for why this category exists as a sibling to `tool/` rather than folding into it. + +## `SlashCommandSpec` vs. `PromptExpansionSpec` + +Two deliberately distinct "slash command" concepts exist in this protocol: + +| | `SlashCommandSpec` (this category) | `pluggableharness.common.v1.PromptExpansionSpec` | +|---|---|---| +| Executes anything | Yes — dispatches through this provider's own `Invoke`, and through the plan/apply gate for `TOOL_KIND_RESOURCE` commands | No — purely a static template expansion | +| Declared by | A `slashcommand.v1` provider, in `GetCapabilitiesResponse.commands` | Any of the other six categories, directly in their own capability response (e.g. [`tool/protocol.md#getschema`](../tool/protocol.md#getschema)'s `slash_commands` field) | +| Has `input_schema`/`kind`/`risk`/`concurrency` | Yes | No — only `name`, `description`, and a `template` string | +| Costs a model turn | Not inherently — a `TOOL_KIND_DATA_SOURCE` or `TOOL_KIND_RESOURCE` command executes directly; nothing about invoking it requires a model turn | Always — the kernel expands `template` with the user's arguments and submits the result as an ordinary `user_message`, which the model then turns on | +| Producer's own name-uniqueness namespace | Its own — a name collision among `SlashCommandSpec`s across every provider in the session is a hard error, independent of the `PromptExpansionSpec` namespace | Its own, independent of the `SlashCommandSpec` namespace above | + +A tool provider (or any other category's provider) wanting a direct-invoke shortcut into one of its own operations implements `SlashCommandService` alongside its own category's service in the same process — `hashicorp/go-plugin` muxes multiple gRPC services per subprocess connection, the same mechanism [`agent-loop/hook-dispatch.md#wire-contract--pluggableharnesshookv1`](../agent-loop/hook-dispatch.md#wire-contract--pluggableharnesshookv1) already relies on for `HookSubscriberService`. See [`conformance.md#open-questions`](conformance.md#open-questions) for the current, expected-common, unresolved shape of that combination. diff --git a/docs/specifications/slashcommand/examples.md b/docs/specifications/slashcommand/examples.md new file mode 100644 index 0000000..8304a2a --- /dev/null +++ b/docs/specifications/slashcommand/examples.md @@ -0,0 +1,118 @@ +# Slash-command provider — examples + +## A slash-command provider block in `agent.hcl` + +```hcl +required_providers { + release-tools = { + source = "github.com/agentco/provider-release-tools" + version = "~> 1.0.0" + } +} + +provider "release-tools" { + changelog_path = "CHANGELOG.md" +} +``` + +As with every other category, `release-tools`'s category is never declared in `agent.hcl` — the kernel discovers it's a `slashcommand.v1` provider at runtime, from its own `GetCapabilities` response, per [`configuration/blocks-reference.md#required_providers`](../configuration/blocks-reference.md#required_providers). `Describe` (see [`protocol.md#describe`](protocol.md#describe)) is the only place `category` appears as an explicit field, and only for the `dev_overrides` identity case. + +## The wire protocol + +This is the wire protocol's service declaration and core call/event messages, in protobuf form (trimmed to the essentials): + +```protobuf +service SlashCommandService { + rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); + rpc Configure(ConfigureRequest) returns (ConfigureResponse); + rpc Invoke(InvokeRequest) returns (stream InvokeResponse); + rpc Render(RenderRequest) returns (RenderResponse); + rpc Preview(PreviewRequest) returns (PreviewResponse); + rpc Describe(DescribeRequest) returns (DescribeResponse); +} + +message SlashCommandCall { + string id = 1; + string name = 2; + google.protobuf.Struct arguments = 3; + pluggableharness.common.v1.CallContext call_context = 4; +} + +message PreviewRequest { + SlashCommandCall call = 1; +} + +message PreviewResponse { + pluggableharness.render.v1.RenderTree preview = 1; +} + +message DescribeRequest {} + +message DescribeResponse { + pluggableharness.common.v1.ProducerRef producer = 1; +} +``` + +`InvokeRequest`/`InvokeResponse` are thin per-RPC envelopes (`{ call = 1; }` / `{ event = 1; }`) around `SlashCommandCall`/`SlashCommandEvent` — see [`data-types.md#slashcommandcall--slashcommandevent`](data-types.md#slashcommandcall--slashcommandevent). + +## A `GetCapabilitiesResponse` snippet + +`release-tools` declaring a single `/changelog` command — a `TOOL_KIND_DATA_SOURCE` read of the repo's changelog file, so it executes freely without going through the plan/apply gate: + +```text +← GetCapabilitiesResponse{ + commands: [ + { + name: "changelog", + description: "Show unreleased entries from CHANGELOG.md", + input_schema: {type: "object", properties: {since: {type: "string"}}}, + kind: TOOL_KIND_DATA_SOURCE, + risk: RISK_CLASS_READ_ONLY, + concurrency: {safe: true}, + streaming: false, + idempotent: true, + } + ], + config_schema: {...}, + supported_hook_points: [], + } +``` + +## A full `Invoke` event sequence + +A `/changelog` call that reads the file and returns its unreleased section, expressed as the `oneof SlashCommandEvent.event` variants: + +```text +→ InvokeRequest{ + call: { + id: "sc_17", + name: "changelog", + arguments: {}, + call_context: {session_id: "01J...", turn_id: "01J...", working_directory: "/home/steven/code/aiagent"}, + } + } + +← InvokeResponse{event: {progress: {message: "reading CHANGELOG.md"}}} +← InvokeResponse{event: {result: {payload: {"unreleased": "- Add slashcommand.v1 category\n"}}}} +``` + +Because `TOOL_KIND_DATA_SOURCE` commands bypass the resource plan/apply gate entirely (per [`protocol.md#invoke`](protocol.md#invoke)), this call never produces a `PlanItem` awaiting approval — it dispatches and streams back immediately. + +If the kernel cancels the stream mid-flight (e.g. the user interrupted the turn while `/changelog` was still reading), the plugin sees the gRPC stream close and — per [`protocol.md#invoke`](protocol.md#invoke), reusing [`tool/protocol.md#invoke`](../tool/protocol.md#invoke)'s cancellation contract — MUST make a best-effort report of what already happened before closing, for any `TOOL_KIND_RESOURCE` command in flight: + +```text +→ InvokeRequest{ + call: { + id: "sc_18", + name: "release", + arguments: {"version": "1.4.0"}, + call_context: {session_id: "01J...", turn_id: "01J...", working_directory: "/home/steven/code/aiagent"}, + } + } + +← InvokeResponse{event: {progress: {message: "tagging v1.4.0"}}} +← InvokeResponse{event: {output_chunk: {stream: OUTPUT_STREAM_STDOUT, data: "Created tag v1.4.0\n"}}} +← InvokeResponse{event: {partial_result: {payload: {"note": "tag created; changelog rewrite cancelled before it ran"}}}} +``` + +— no terminal `result` or `error` follows; the kernel's own bookkeeping records the call as cancelled once the stream closes without one, per [`conformance.md#error-taxonomy`](conformance.md#error-taxonomy)'s `cancelled` category, the same reused `tool.v1.ToolErrorCategory` value [`tool/conformance.md#error-taxonomy`](../tool/conformance.md#error-taxonomy) defines. diff --git a/docs/specifications/slashcommand/protocol.md b/docs/specifications/slashcommand/protocol.md new file mode 100644 index 0000000..0dfc30f --- /dev/null +++ b/docs/specifications/slashcommand/protocol.md @@ -0,0 +1,51 @@ +# Slash-command provider — protocol + +The three RPCs a slash-command provider plugin exposes, plus `Describe` (MUST) and the optional `Render`/`Preview`. See [`README.md`](README.md#transport--lifecycle) for the transport-level framing (server-streaming, cancellation) that applies to `Invoke` specifically. + +## `GetCapabilities` + +Returns a list of `SlashCommandSpec` values, one per direct-invoke command the plugin exposes. Like [`tool/protocol.md#getschema`](../tool/protocol.md#getschema), this MUST be re-queryable cheaply and MUST NOT require a network call — only `Invoke` talks to the network. See [`data-types.md#slashcommandspec`](data-types.md#slashcommandspec) for the full message shape. + +`kind`, `risk`, and `concurrency` are the same `pluggableharness.tool.v1` types and carry the identical MUST-level semantics [`tool/protocol.md#getschema`](../tool/protocol.md#getschema) already specifies — `kind` drives the plan/apply gate exactly as it does for a tool operation, `risk` MUST be one of `low`/`moderate`/`high`/`critical` for `TOOL_KIND_RESOURCE` and MUST be `read_only` for `TOOL_KIND_DATA_SOURCE`/`TOOL_KIND_INTERACTIVE`, and `TOOL_KIND_INTERACTIVE` commands MUST NOT declare a `ConcurrencySpec` and MUST execute sequentially — see [`tool/protocol.md#kind-interactive`](../tool/protocol.md#kind-interactive). This category does not restate that reasoning; it applies verbatim to a `SlashCommandSpec` the same way it applies to a `ToolSchema`. + +`default_timeout` and `idempotent` carry the same meaning [`tool/protocol.md#getschema`](../tool/protocol.md#getschema) defines for the identically-named `ToolSchema` fields: `default_timeout` is the deadline the kernel applies to `Invoke` absent an `agent.hcl` override, and `idempotent` gates whether the kernel MAY auto-retry a retryable `ToolError` for a `TOOL_KIND_RESOURCE` command without first surfacing the failure — see [`tool/conformance.md#the-idempotent--retry-interaction`](../tool/conformance.md#the-idempotent--retry-interaction). + +Unlike `ToolSchema`, `SlashCommandSpec` declares no `output_schema`: a direct-invoke command is never presented to the model as a callable tool and dispatches without a model turn, so there is no LLM-facing structured-output contract to validate its result against. + +The `GetCapabilitiesResponse` wrapper around this list also carries `config_schema` (this provider's `agent.hcl` config schema, per [`configuration/blocks-reference.md#the-schema-to-cty-bridge`](../configuration/blocks-reference.md#the-schema-to-cty-bridge)) and `supported_hook_points: []pluggableharness.common.v1.HookPoint`, naming which of the eight dispatchable hook points ([`agent-loop/hook-dispatch.md`](../agent-loop/hook-dispatch.md)) this provider's `HookSubscriberService` subscribes to per its own `agent.hcl` `hook{}` blocks — same capability-advertisement semantics as every other plugin category: it lets the kernel validate a `hook{}` declaration against what the plugin actually supports at config-load time, rather than discovering an unsupported subscription only when that hook point first fires. + +## `Configure` + +Same contract as [`tool/protocol.md#configure`](../tool/protocol.md#configure): config decoded from the provider's `agent.hcl` block via the schema-to-`cty` bridge; field contents are provider-specific. + +- `Configure` MUST reject with a structured error on missing required fields rather than deferring failure to the first `Invoke`. +- A plugin MUST NOT echo a received secret into an `Emit`'d event, `Render` output, log line, or error message. +- A slash-command provider needing a capability boundary (a filesystem root, a sandbox policy, a domain allowlist) declares it as an ordinary `Configure` field, exactly as [`tool/protocol.md#configure`](../tool/protocol.md#configure) describes for a tool provider — this protocol does not mandate a specific field name or enforcement mechanism. + +## `Invoke` + +Request: a `SlashCommandCall`. Response: a stream of `SlashCommandEvent`s. See [`data-types.md#slashcommandcall--slashcommandevent`](data-types.md#slashcommandcall--slashcommandevent) for the full message shapes and [`examples.md#a-full-invoke-event-sequence`](examples.md#a-full-invoke-event-sequence) for a worked sequence. + +`Invoke` dispatches through the plan/apply gate exactly like [`tool/protocol.md#invoke`](../tool/protocol.md#invoke) — this section does not restate that RPC's semantics (streaming shape, cancellation, `output_chunk` ordering, `exit_status` rules, best-effort partial-mutation reporting on cancellation) since they apply here verbatim, with `SlashCommandCall`/`SlashCommandEvent` standing in for `ToolCall`/`ToolEvent`. The one difference: `tool/protocol.md#invoke`'s strict `output_schema` enforcement rule has no analogue here, since `SlashCommandSpec` declares no `output_schema` (see [`GetCapabilities`](#getcapabilities) above) — the kernel accepts a `SlashCommandEvent.result.payload` without a schema to validate it against. + +A `SlashCommandCall` produces a `pluggableharness.plan.v1.PlanItem` with `producer_category == CATEGORY_SLASHCOMMAND`, exactly parallel to how a `ToolCall` produces one with `CATEGORY_TOOL` — see [`agent-loop/plan-apply-gate.md#plan-construction-and-policy-evaluation`](../agent-loop/plan-apply-gate.md#plan-construction-and-policy-evaluation). Both `producer_category` values flow through one shared `Plan`/`PlanItem` type and one policy evaluation path; a `TOOL_KIND_RESOURCE` command is gated exactly as a `TOOL_KIND_RESOURCE` tool call is, and a `TOOL_KIND_DATA_SOURCE`/`TOOL_KIND_INTERACTIVE` command follows the same non-interactive policy precheck lane a `data_source`/`interactive` tool call uses, per [`tool/protocol.md#kind-interactive`](../tool/protocol.md#kind-interactive). + +`SlashCommandCall.call_context` MUST be set by the kernel on every `Invoke` call, identically to `ToolCall.call_context` — see [`data-types.md#slashcommandcall--slashcommandevent`](data-types.md#slashcommandcall--slashcommandevent). + +## Render + +Same optionality and reasoning as [`tool/protocol.md#render`](../tool/protocol.md#render) — returning the `RenderTree` formally defined in [`frontend/render-tree.md`](../frontend/render-tree.md). If not implemented, the kernel falls back to its generic default (pretty-printed JSON payload). `RenderRequest.schema_version` names which version of the plugin's own emitted-payload schema `payload` was written under, per [`frontend/render-tree.md#schema-versioning`](../frontend/render-tree.md#schema-versioning). + +## Preview + +`Preview` returns a dry-run, human-readable description of what `Invoke(call)` *would* do, without doing it — same contract as [`tool/protocol.md#preview`](../tool/protocol.md#preview). Request: a `PreviewRequest` wrapping the same `SlashCommandCall` shape `Invoke` takes; MUST NOT actually be executed by the plugin. Response: a `PreviewResponse` wrapping a `RenderTree` (the same [`frontend/render-tree.md`](../frontend/render-tree.md) type `Render` returns). + +- MAY be implemented. A kernel MUST tolerate its absence and fall back to showing the call's raw `arguments` in the plan/apply gate's permission UI. +- MUST NOT mutate anything and MUST be side-effect-free, unconditionally regardless of the call's declared `kind` — the same guarantee [`tool/protocol.md#preview`](../tool/protocol.md#preview) makes. A plugin that cannot produce a preview without performing (part of) the operation MUST NOT implement `Preview` for that command. +- Exists specifically to feed `pluggableharness.plan.v1.PlanItem.preview` for a `producer_category == CATEGORY_SLASHCOMMAND` item, the same dry-run-feeds-the-plan-item mechanism [`tool/protocol.md#preview`](../tool/protocol.md#preview) describes for `CATEGORY_TOOL` — see [`agent-loop/plan-apply-gate.md#preview-flow`](../agent-loop/plan-apply-gate.md#preview-flow). `PlanItem.preview` and `PreviewResponse.preview` are pinned to the exact same `pluggableharness.render.v1.RenderTree` type by design, so the kernel can store a `Preview` call's output directly as a plan item's preview without any conversion, regardless of which of the two categories produced it. + +## Describe + +`Describe` reports this plugin build's own identity: request is empty (`DescribeRequest {}`), response is a `DescribeResponse` wrapping a single `pluggableharness.common.v1.ProducerRef producer`. MUST be implemented — every one of the seven category protocols carries this RPC. + +This exists for the [`configuration/lock-file.md`](../configuration/lock-file.md#dev_overrides-and-identity-without-a-lock-entry) `dev_overrides` case: a plugin resolved via `dev_overrides` has no `provider "" { ... }` lock-file entry for the kernel to read `{name, version, source, category, protocol_version}` from. `Describe` lets the kernel obtain that same identity directly from the running process instead, at connection time. diff --git a/docs/specifications/state-backend.md b/docs/specifications/state-backend.md index 827f59f..d29a3be 100644 --- a/docs/specifications/state-backend.md +++ b/docs/specifications/state-backend.md @@ -32,7 +32,7 @@ CREATE TABLE events ( id TEXT NOT NULL UNIQUE, -- stable event identifier, independent of storage timestamp TEXT NOT NULL, -- wall-clock, display only, not ordering-authoritative kind TEXT NOT NULL, -- see "The kind enum" below for the authoritative enum - producer_category TEXT NOT NULL, -- model | tool | context | memory | frontend | widget + producer_category TEXT NOT NULL, -- model | tool | context | memory | frontend | widget | slashcommand producer_name TEXT NOT NULL, producer_version TEXT NOT NULL, schema_version TEXT NOT NULL, @@ -156,7 +156,7 @@ kind = enum { } ``` -Each `kind` above decodes to exactly one concrete message in `pluggableharness.event.v1` (`api/pluggableharness/event/v1/event.proto`) — that package defines no enum of its own; this table, together with `kernel-callbacks.md#emit`'s restatement of the same enum, is the sole source of the kind → message mapping: +Each `kind` above decodes to exactly one concrete message in `pluggableharness.event.v1` (`api/pluggableharness/event/v1/events.proto`) — that package defines no enum of its own; this table, together with `kernel-callbacks.md#emit`'s restatement of the same enum, is the sole source of the kind → message mapping: | `kind` | `event.v1` message | |---|---| diff --git a/docs/specifications/tool/README.md b/docs/specifications/tool/README.md index 38363f3..5bf879a 100644 --- a/docs/specifications/tool/README.md +++ b/docs/specifications/tool/README.md @@ -8,7 +8,7 @@ This category depends directly on [`model/`](../model/README.md): the common JSO ## Transport & lifecycle -Subprocess + gRPC via `hashicorp/go-plugin`, per [`architecture.md`](../architecture.md#transport). Standard handshake (magic cookie, protocol version negotiation) applies uniformly across all six provider categories and isn't repeated per category. +Subprocess + gRPC via `hashicorp/go-plugin`, per [`architecture.md`](../architecture.md#transport). Standard handshake (magic cookie, protocol version negotiation) applies uniformly across all seven provider categories and isn't repeated per category. A tool provider plugin exposes four RPCs: `GetSchema`, `Configure`, `Invoke`, `Describe`. It MAY additionally implement `Render` (see [`protocol.md#render`](protocol.md#render)) and `Preview` (see [`protocol.md#preview`](protocol.md#preview)). diff --git a/docs/specifications/tool/protocol.md b/docs/specifications/tool/protocol.md index 9ca9980..68925ec 100644 --- a/docs/specifications/tool/protocol.md +++ b/docs/specifications/tool/protocol.md @@ -43,7 +43,7 @@ A genuine third `kind`, alongside `resource` and `data_source`, for calls that n - `interactive` calls MUST still pass through a policy precheck before executing — the same non-interactive, `allow`/`deny`-only lane [`configuration/policy-dsl.md`](../configuration/policy-dsl.md) already defines for `data_source` calls, extended to cover this kind too. This exists specifically so an operator can `deny` interactive prompts outright in a non-interactive/headless invocation (a future pipeline mode per [`architecture.md`](../architecture.md#cli-shape)) where there is no human attached to answer one — without policy coverage, an `ask_user`-shaped call in a headless context would simply hang forever with no one able to respond. Note that policy's own `Match.Kind` field stays two-valued (`resource`/`data_source`) in v1 — an interactive call routes through the same non-interactive-style precheck path a `data_source` call uses, rather than policy gaining a third match kind of its own. See [`configuration/policy-dsl.md`](../configuration/policy-dsl.md#match-schema). - `interactive` calls MUST execute **sequentially**, never concurrently with other `interactive` calls in the same turn, regardless of any declared `ConcurrencySpec` — asking a human two things at once in one frontend is inherently confusing. `ConcurrencySpec` MUST NOT be declared for an `interactive` operation; if present, the kernel MUST ignore it and enforce sequential execution unconditionally. -The overall `GetSchema` response (the wrapper around this list of `ToolSchema`s) MAY additionally include `slash_commands: []SlashCommandSpec`, per [`frontend/frontend-protocol.md`](../frontend/frontend-protocol.md) — each entry's `tool_name` MUST reference one of this same provider's own operations declared above, and `supported_hook_points: []pluggableharness.common.v1.HookPoint`, naming which of the eight dispatchable hook points (`agent-loop/hook-dispatch.md`) this provider's `HookSubscriberService` subscribes to per its own `agent.hcl` `hook{}` blocks. Same capability-advertisement semantics as the other five plugin categories: it lets the kernel validate a `hook{}` declaration against what the plugin actually supports at config-load time, rather than discovering an unsupported subscription only when that hook point first fires. +The overall `GetSchema` response (the wrapper around this list of `ToolSchema`s) MAY additionally include `slash_commands: []common.v1.PromptExpansionSpec`, per [`frontend/frontend-protocol.md`](../frontend/frontend-protocol.md) — a static template-expansion command only, with no `tool_name` field and no way to invoke anything: the kernel expands `template` with the user's arguments and submits the result as an ordinary user message. A tool provider wanting a direct-invoke shortcut into one of its own operations does not declare it here — it implements `SlashCommandService` ([`../slashcommand/protocol.md`](../slashcommand/protocol.md)) alongside `ToolService` in the same process; `hashicorp/go-plugin` natively muxes multiple gRPC services over one subprocess connection, the same pattern `hook.v1.HookSubscriberService` already uses ([`agent-loop/hook-dispatch.md`](../agent-loop/hook-dispatch.md)) to let a plugin expose more than one service without a second connection. The response also carries `supported_hook_points: []pluggableharness.common.v1.HookPoint`, naming which of the eight dispatchable hook points (`agent-loop/hook-dispatch.md`) this provider's `HookSubscriberService` subscribes to per its own `agent.hcl` `hook{}` blocks. Same capability-advertisement semantics as the other six plugin categories: it lets the kernel validate a `hook{}` declaration against what the plugin actually supports at config-load time, rather than discovering an unsupported subscription only when that hook point first fires. ## `Configure` @@ -83,6 +83,6 @@ Same optionality as [`model/protocol.md#render`](../model/protocol.md#render) ## Describe -`Describe` reports this plugin build's own identity: request is empty (`DescribeRequest {}`), response is a `DescribeResponse` wrapping a single `pluggableharness.common.v1.ProducerRef producer`. MUST be implemented — every one of the six category protocols gains this RPC in this same protocol revision. +`Describe` reports this plugin build's own identity: request is empty (`DescribeRequest {}`), response is a `DescribeResponse` wrapping a single `pluggableharness.common.v1.ProducerRef producer`. MUST be implemented — every one of the seven category protocols gains this RPC in this same protocol revision. This exists for the [`configuration/lock-file.md`](../configuration/lock-file.md#dev_overrides-and-identity-without-a-lock-entry) `dev_overrides` case: a plugin resolved via `dev_overrides` has no `provider "" { ... }` lock-file entry for the kernel to read `{name, version, source, category, protocol_version}` from, because `dev_overrides` exists precisely to bypass registry/lock-file resolution. `Describe` lets the kernel obtain that same identity directly from the running process instead, at connection time. diff --git a/go.mod b/go.mod index 4262821..ada1f1e 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 @@ -26,6 +27,8 @@ require ( go.opentelemetry.io/otel/sdk/log v0.20.0 go.opentelemetry.io/otel/sdk/metric v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 + go.opentelemetry.io/proto/otlp v1.10.0 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa google.golang.org/grpc v1.82.1 google.golang.org/protobuf v1.36.11 modernc.org/sqlite v1.54.0 @@ -52,8 +55,6 @@ require ( github.com/oklog/run v1.1.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect - go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.22.0 // indirect @@ -61,7 +62,6 @@ require ( golang.org/x/text v0.40.0 // indirect golang.org/x/tools v0.47.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // indirect modernc.org/libc v1.74.1 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/internal/eventbus/CLAUDE.md b/internal/eventbus/CLAUDE.md index 0e99ee9..e8ab69f 100644 --- a/internal/eventbus/CLAUDE.md +++ b/internal/eventbus/CLAUDE.md @@ -12,4 +12,8 @@ - **`EventBusTopicKey` (internal/telemetry/attributes.go) is span-only, same cardinality rule as `SessionIDKey`/`FilePathKey`.** `Topic` is an arbitrary caller-chosen string, so it must never become a metric attribute (`internal/telemetry/CLAUDE.md`'s cardinality rule). The three `EventBusEvents*`/`EventBusSubscriptionsActive` instruments (`internal/telemetry/instrument.go`) deliberately carry no topic attribute at all — don't add one, even as a "just for debugging" temporary measure. -- **This package has zero integration with anything else in the repo, on purpose — confirm before wiring it up.** No `agent-loop` package calls `Subscribe`/`Publish`, no plugin RPC feeds from it, and no `docs/specifications/` document mentions it (confirmed absent during design — see doc.go). If a future task wires plugin RPCs to Bus events, that's new work with its own spec question (`kernel-callbacks.md`'s open "future kernel primitives" question) — don't assume this package's existence already implies that decision was made. +- **`internal/kernelcallback` is now this package's one caller, via `Bus.Publish`/`Bus.SubscribeFilters`** (`internal/kernelcallback/eventbus.go`'s `Publish`/`Subscribe` RPC handlers) — `docs/specifications/event-bus.md` is the spec document that settled this. No `agent-loop` package calls into this package directly yet, and that's still out of scope here. + +- **`SubscribeFilters`' wildcard registry (`wildcardEntry`/`Bus.wildcards`, `eventbus.go`) is scanned linearly per `Publish`, not indexed.** Deliberate: subscriber counts are expected to stay small (per-plugin, per-process), so a trie or prefix index would be premature complexity for a scan that's already O(subscriptions) in the common case. `matchesFilter`/`isWildcardFilter`/`wildcardPrefix` (`filter.go`) are the actual matching logic — `Publish` itself only calls `strings.HasPrefix` directly against `wildcardEntry.prefix` (already stripped of its trailing `*`) rather than calling back into `matchesFilter`, to avoid re-deriving the prefix on every Publish; don't "simplify" that into a `matchesFilter` call without checking whether it reintroduces the redundant `TrimSuffix` per publish. + +- **A `Subscription`'s `filters` field can mix exact and wildcard entries, and `Bus.remove` must clean up both structures for the same `Subscription`.** `remove` iterates `sub.filters`, skipping wildcard entries in its exact-map pass (they were never registered there) and pruning `Bus.wildcards` in a separate pass at the end. If you add a new filter-storage structure, update `remove` to prune it too — a `Subscription` that's `Close`d but still partially registered would keep receiving (or keep pinning memory for) events after the caller believes it's gone. diff --git a/internal/eventbus/README.md b/internal/eventbus/README.md index 2322459..d437f64 100644 --- a/internal/eventbus/README.md +++ b/internal/eventbus/README.md @@ -6,23 +6,24 @@ An ephemeral, in-process publish/subscribe event bus for the kernel. `Bus` lets any number of in-process components exchange `Event`s by topic, for the lifetime of one kernel process. It is deliberately not persistent: there is no history, no backlog, and no replay. A published `Event` is fanned out to every subscription currently registered on its `Topic`, and once that fan-out step returns, the `Bus` retains no reference to it — closing the process (or the `Bus` itself) discards everything, and a fresh `Bus` starts empty. -This exists so plugins can eventually be fed data pushed to them out-of-band from the kernel's own request/response RPC calls, rather than only ever being called synchronously. **This package does not wire that up.** It is a standalone, self-contained primitive: no agent-loop integration, no plugin RPC, no `docs/specifications/` entry. See `doc.go`'s "Design decisions" section for why, and what a future integration would need to add. +This lets plugins be fed data pushed to them out-of-band from the kernel's own request/response RPC calls, rather than only ever being called synchronously. **The plugin-facing wiring now exists**: `internal/kernelcallback`'s `Publish`/`Subscribe` RPC handlers (`eventbus.go` there) bridge `KernelCallbackService.Publish`/`.Subscribe` to this package's `Bus.Publish`/`Bus.SubscribeFilters` — see `docs/specifications/event-bus.md` for the wire-facing topic grammar, filter grammar, and delivery semantics that bridge implements, and `doc.go`'s "Design decisions" section for this package's own, lower-level design record. -## Why it isn't a wire protocol +## Where it sits relative to the wire protocol -`docs/specifications/` already uses "emit," "subscribe," and "broadcast" for three unrelated things — state-backend `Emit` (persist an opaque event), the synchronous ordered hook-dispatch chain (`agent-loop/hook-dispatch.md`), and frontend `ServerEvent` broadcast (`frontend/frontend-protocol.md`). None of them is a pub/sub bus, and confirming that gap was part of this package's own design process. `internal/eventbus` sits below all of that — it never crosses the `hashicorp/go-plugin` boundary, so it has no proto, no category, and no spec document of its own; it's kernel-internal plumbing in the same sense as `internal/telemetry` or `internal/producer`. +`docs/specifications/event-bus.md` is this package's wire-facing counterpart, distinguishing it from three other "event"-shaped mechanisms already in this project: state-backend `Emit` (durable, sequenced), the synchronous ordered hook-dispatch chain (`agent-loop/hook-dispatch.md`, static, `agent.hcl`-declared, can veto), and frontend `ServerEvent` broadcast (`frontend/frontend-protocol.md`, connection-scoped). None of those three is a pub/sub bus. `internal/eventbus` itself still never crosses the `hashicorp/go-plugin` boundary directly and has no proto of its own — `internal/kernelcallback` is the seam that does that translation, using `kernel.v1`'s `PublishRequest`/`BusEvent` messages. ## Shape -- `Bus` (`eventbus.go`) — the registry (`topic -> subscriptions`) plus `Publish`/`Subscribe`/`Close`. +- `Bus` (`eventbus.go`) — the registry (an exact-topic map plus a linearly-scanned trailing-wildcard list, `filter.go`) plus `Publish`/`Subscribe`/`SubscribeFilters`/`Close`. - `Event` (`event.go`) — `{ Topic string; Payload any }`. See its doc comment for the read-only contract on `Payload`. -- `Subscription` (`subscription.go`) — one open registration: a `Handler` invoked on its own dedicated delivery goroutine, fed by an unbounded per-subscriber `queue` (`queue.go`). +- `Subscription` (`subscription.go`) — one open registration under one or more filters: a `Handler` invoked on its own dedicated delivery goroutine, fed by an unbounded per-subscriber `queue` (`queue.go`). +- `filter.go` — `isWildcardFilter`/`wildcardPrefix`/`matchesFilter`, the exact-or-trailing-`*` matching `SubscribeFilters` and `Publish` share. Deliberately permissive (any trailing `*` counts) — the stricter wire-level grammar (`event-bus.md`'s "ending in `.*`," whole segments only) is validated by `internal/kernelcallback`'s RPC boundary, not here. Confirmed design choices (settled with the requester before implementation, recorded in full in `doc.go`): -1. **Handler-callback subscription API** — `Subscribe(ctx, topic, handler)`, not a channel the caller reads itself. -2. **Unbounded, never-blocking, never-dropping delivery** — `Publish` always returns immediately; a slow subscriber only grows its own queue, never anyone else's, and never stalls a publisher. -3. **`any` payload routed by a string `Topic`** — one `Bus` carries heterogeneous event kinds. +1. **Handler-callback subscription API** — `Subscribe(ctx, topic, handler)`, not a channel the caller reads itself. `SubscribeFilters(ctx, filters, handler)` extends this to multiple exact-or-wildcard filters per subscription; `Subscribe` is sugar over it for the single-exact-topic case. +2. **Unbounded, never-blocking, never-dropping delivery** — `Publish` always returns immediately; a slow subscriber only grows its own queue, never anyone else's, and never stalls a publisher. `internal/kernelcallback`'s `Subscribe` RPC layers its own separate, bounded buffer on top of a `Subscription` for the plugin-facing gRPC stream (`event-bus.md#backpressure`) — that bound lives entirely in the bridge; this guarantee is unchanged here. +3. **`any` payload routed by a string `Topic`** — one `Bus` carries heterogeneous event kinds. `internal/kernelcallback`'s bridge always uses `*kernelv1.BusEvent` as the payload shape for anything reaching a plugin. ## Using it diff --git a/internal/eventbus/doc.go b/internal/eventbus/doc.go index 1403e06..e0c98a9 100644 --- a/internal/eventbus/doc.go +++ b/internal/eventbus/doc.go @@ -5,25 +5,29 @@ // replay — closing the process (or the Bus itself) discards everything; // a fresh Bus starts empty and must be re-filled by its publishers. // -// This is kernel-internal plumbing, not a plugin-protocol category: it -// sits below the wire protocol entirely, and nothing in -// docs/specifications/ describes it (confirmed absent during design — -// the existing "emit"/"subscribe"/"broadcast" vocabulary there names -// three unrelated things: state-backend Emit, the synchronous ordered -// hook-dispatch chain, and frontend ServerEvent broadcast; none of them -// is a pub/sub bus, and none exists anywhere in internal/ or pkg/ prior -// to this package). This package deliberately does not integrate with -// any of them — no agent-loop wiring, no plugin RPC, no docs/specifications/ -// entry. When a later change feeds plugin RPCs from Bus events, that -// integration is a separate, spec-first effort. +// This started as kernel-internal plumbing with no plugin-facing wire +// protocol of its own — docs/specifications/event-bus.md now names it +// explicitly, as the mechanism behind KernelCallbackService's Publish/ +// Subscribe RPCs (internal/kernelcallback's eventbus.go bridges those two +// RPCs to this package's Bus). event-bus.md's own boundary section +// distinguishes this from the other three "event"-shaped mechanisms +// already in this project: state-backend Emit (durable, sequenced), the +// synchronous ordered hook-dispatch chain (static, agent.hcl-declared, +// can veto), and frontend ServerEvent broadcast (connection-scoped) — +// none of them is a pub/sub bus, and this package remains the only one. // -// Bus (eventbus.go) holds a topic -> subscriber registry guarded by a -// mutex; Publish (eventbus.go) fans an Event out to every current -// subscriber of its Topic and returns immediately — it never blocks and -// never drops. Subscribe (eventbus.go) returns a Subscription -// (subscription.go), each with its own unbounded per-subscriber queue -// (queue.go) drained by a dedicated delivery goroutine that invokes the -// registered Handler out-of-band from any publisher. +// Bus (eventbus.go) holds a topic -> subscriber registry (an exact-match +// map plus a linearly-scanned trailing-wildcard list, filter.go) guarded +// by a mutex; Publish (eventbus.go) fans an Event out to every current +// subscriber whose registration matches its Topic and returns immediately +// — it never blocks and never drops. Subscribe/SubscribeFilters +// (eventbus.go) return a Subscription (subscription.go), each with its +// own unbounded per-subscriber queue (queue.go) drained by a dedicated +// delivery goroutine that invokes the registered Handler out-of-band from +// any publisher. internal/kernelcallback's Subscribe RPC layers its own, +// separate bounded buffer on top of a Subscription for the plugin-facing +// stream (event-bus.md#backpressure) — that bound lives entirely in the +// bridge; this package's own contract is unchanged by it. // // # Design decisions // diff --git a/internal/eventbus/eventbus.go b/internal/eventbus/eventbus.go index c458c7c..d231754 100644 --- a/internal/eventbus/eventbus.go +++ b/internal/eventbus/eventbus.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strings" "sync" "sync/atomic" @@ -18,12 +19,24 @@ import ( // a serious amount of retained memory. const defaultQueueWarnThreshold = 1024 +// wildcardEntry is one Subscription's registration under one wildcard +// filter — prefix is that filter with its trailing "*" already stripped +// (filter.go's wildcardPrefix). Bus.wildcards holds one entry per +// (Subscription, wildcard filter) pair, scanned linearly by Publish; +// exact filters never appear here — they live in Bus.subs instead, which +// stays a direct map lookup. +type wildcardEntry struct { + prefix string + sub *Subscription +} + // Bus is an ephemeral, in-process publish/subscribe fan-out — see doc.go // for the full contract. The zero value is not usable; construct one with // New. type Bus struct { - mu sync.RWMutex - subs map[string]map[*Subscription]struct{} // topic -> that topic's open subscriptions + mu sync.RWMutex + subs map[string]map[*Subscription]struct{} // exact topic filter -> that topic's open subscriptions + wildcards []wildcardEntry // trailing-wildcard filters, scanned per Publish logger *slog.Logger telemetry *telemetry.Provider @@ -136,10 +149,28 @@ func (b *Bus) Publish(ctx context.Context, event Event) error { // today) hold the lock indefinitely. b.mu.RLock() topicSubs := b.subs[event.Topic] + // seen dedupes a Subscription that matches via more than one + // registration (an exact hit and a wildcard hit, or two overlapping + // wildcard filters on the same Subscription) so it's enqueued exactly + // once per Publish call, never once per matching filter. + seen := make(map[*Subscription]struct{}, len(topicSubs)) targets := make([]*Subscription, 0, len(topicSubs)) for sub := range topicSubs { + if _, dup := seen[sub]; dup { + continue + } + seen[sub] = struct{}{} targets = append(targets, sub) } + for _, entry := range b.wildcards { + if _, dup := seen[entry.sub]; dup { + continue + } + if strings.HasPrefix(event.Topic, entry.prefix) { + seen[entry.sub] = struct{}{} + targets = append(targets, entry.sub) + } + } b.mu.RUnlock() for _, sub := range targets { @@ -153,34 +184,68 @@ func (b *Bus) Publish(ctx context.Context, event Event) error { // Subscribe registers handler to receive every future Event published // with the given topic, returning a Subscription the caller uses to -// unregister it (Subscription.Close). handler runs on a dedicated +// unregister it (Subscription.Close). It is sugar for +// SubscribeFilters(ctx, []string{topic}, handler) — see that method for +// the full contract, including trailing-wildcard filter support. +// +// Subscribe returns ErrClosed if the Bus has already been closed, +// ErrEmptyTopic if topic is empty, and ErrNilHandler if handler is nil. +func (b *Bus) Subscribe(ctx context.Context, topic string, handler Handler) (*Subscription, error) { + if topic == "" { + return nil, ErrEmptyTopic + } + return b.SubscribeFilters(ctx, []string{topic}, handler) +} + +// SubscribeFilters registers handler to receive every future Event whose +// Topic matches any of filters, returning a Subscription the caller uses +// to unregister it (Subscription.Close). handler runs on a dedicated // delivery goroutine, out-of-band from any Publish call (Handler's doc // comment). The subscription's lifetime is bounded by both ctx (canceling // or letting ctx expire stops delivery, exactly as calling // Subscription.Close would) and by an explicit Close call — whichever // comes first. // -// Subscribe returns ErrClosed if the Bus has already been closed, -// ErrEmptyTopic if topic is empty, and ErrNilHandler if handler is nil. -func (b *Bus) Subscribe(ctx context.Context, topic string, handler Handler) (*Subscription, error) { - if topic == "" { +// Each entry in filters is either an exact topic (matches only that exact +// string) or a trailing-wildcard filter ending in "*" (matches any topic +// sharing the filter's prefix — filter.go's isWildcardFilter/matchesFilter). +// A single Subscription may mix both kinds; an Event matching more than +// one of a Subscription's filters is still delivered to it exactly once +// per Publish call (Publish's own dedup), never once per matching filter. +// +// SubscribeFilters returns ErrClosed if the Bus has already been closed, +// ErrEmptyTopic if filters is empty or any entry is empty, and +// ErrNilHandler if handler is nil. +func (b *Bus) SubscribeFilters(ctx context.Context, filters []string, handler Handler) (*Subscription, error) { + if len(filters) == 0 { return nil, ErrEmptyTopic } + for _, f := range filters { + if f == "" { + return nil, ErrEmptyTopic + } + } if handler == nil { return nil, ErrNilHandler } - sub := newSubscription(ctx, b, topic, handler, b.logger, b.telemetry, b.queueWarnThreshold) + sub := newSubscription(ctx, b, filters, handler, b.logger, b.telemetry, b.queueWarnThreshold) b.mu.Lock() if b.closed.Load() { b.mu.Unlock() return nil, ErrClosed } - if b.subs[topic] == nil { - b.subs[topic] = make(map[*Subscription]struct{}) + for _, f := range filters { + if isWildcardFilter(f) { + b.wildcards = append(b.wildcards, wildcardEntry{prefix: wildcardPrefix(f), sub: sub}) + continue + } + if b.subs[f] == nil { + b.subs[f] = make(map[*Subscription]struct{}) + } + b.subs[f][sub] = struct{}{} } - b.subs[topic][sub] = struct{}{} b.mu.Unlock() // start is deliberately called only after the lock above is released: @@ -190,13 +255,14 @@ func (b *Bus) Subscribe(ctx context.Context, topic string, handler Handler) (*Su // goroutine's very first iteration. sub.start() - b.logger.DebugContext(ctx, "eventbus: subscribed", "topic", topic) + b.logger.DebugContext(ctx, "eventbus: subscribed", "filters", filters) b.telemetry.Instruments().EventBusSubscriptionsActive.Add(ctx, 1) return sub, nil } -// remove unregisters sub from b's registry. Called exactly once per -// Subscription, from the end of its own deliverLoop — never called +// remove unregisters sub from b's registry — every exact-filter bucket +// and every wildcard entry it was registered under. Called exactly once +// per Subscription, from the end of its own deliverLoop — never called // directly by Subscription.Close, which only signals and waits (see // subscription.go). Safe to call after Bus.Close has already cleared // b.subs: deleting from (and reading from) a nil map is a documented Go @@ -205,12 +271,28 @@ func (b *Bus) remove(sub *Subscription) { b.mu.Lock() defer b.mu.Unlock() - if set, ok := b.subs[sub.topic]; ok { - delete(set, sub) - if len(set) == 0 { - delete(b.subs, sub.topic) + for _, f := range sub.filters { + if isWildcardFilter(f) { + continue // wildcard entries are pruned in the pass below + } + if set, ok := b.subs[f]; ok { + delete(set, sub) + if len(set) == 0 { + delete(b.subs, f) + } } } + + if len(b.wildcards) > 0 { + kept := b.wildcards[:0] + for _, entry := range b.wildcards { + if entry.sub != sub { + kept = append(kept, entry) + } + } + b.wildcards = kept + } + b.telemetry.Instruments().EventBusSubscriptionsActive.Add(sub.ctx, -1) } diff --git a/internal/eventbus/eventbus_test.go b/internal/eventbus/eventbus_test.go index 9da809b..f8dfb63 100644 --- a/internal/eventbus/eventbus_test.go +++ b/internal/eventbus/eventbus_test.go @@ -459,3 +459,147 @@ func hasMetric(rm metricdata.ResourceMetrics, name string) bool { } return false } + +func TestBus_subscribeFilters_wildcardMatch(t *testing.T) { + t.Parallel() + + b := New() + t.Cleanup(func() { _ = b.Close() }) + + got := make(chan Event, 2) + sub, err := b.SubscribeFilters(context.Background(), []string{"plugin.tool.github.*"}, func(_ context.Context, ev Event) { + got <- ev + }) + if err != nil { + t.Fatalf("SubscribeFilters: %v", err) + } + t.Cleanup(func() { _ = sub.Close() }) + + if err := b.Publish(context.Background(), Event{Topic: "plugin.tool.github.file_changed", Payload: "a"}); err != nil { + t.Fatalf("Publish: %v", err) + } + if err := b.Publish(context.Background(), Event{Topic: "plugin.tool.gitlab.file_changed", Payload: "b"}); err != nil { + t.Fatalf("Publish: %v", err) + } + if err := b.Publish(context.Background(), Event{Topic: "plugin.tool.github.pr_opened", Payload: "c"}); err != nil { + t.Fatalf("Publish: %v", err) + } + + first := recvOrTimeout(t, got) + second := recvOrTimeout(t, got) + if first.Payload != "a" || second.Payload != "c" { + t.Fatalf("got payloads %v, %v; want a, c (gitlab event must not match the github.* filter)", first.Payload, second.Payload) + } + select { + case ev := <-got: + t.Fatalf("received unexpected third event %+v", ev) + case <-time.After(50 * time.Millisecond): + } +} + +func TestBus_subscribeFilters_mixedExactAndWildcard(t *testing.T) { + t.Parallel() + + b := New() + t.Cleanup(func() { _ = b.Close() }) + + got := make(chan Event, 4) + sub, err := b.SubscribeFilters(context.Background(), []string{"kernel.event.tool_call", "plugin.tool.github.*"}, func(_ context.Context, ev Event) { + got <- ev + }) + if err != nil { + t.Fatalf("SubscribeFilters: %v", err) + } + t.Cleanup(func() { _ = sub.Close() }) + + if err := b.Publish(context.Background(), Event{Topic: "kernel.event.tool_call"}); err != nil { + t.Fatalf("Publish: %v", err) + } + if err := b.Publish(context.Background(), Event{Topic: "plugin.tool.github.file_changed"}); err != nil { + t.Fatalf("Publish: %v", err) + } + if err := b.Publish(context.Background(), Event{Topic: "kernel.event.message"}); err != nil { + t.Fatalf("Publish: %v", err) + } + + recvOrTimeout(t, got) + recvOrTimeout(t, got) + select { + case ev := <-got: + t.Fatalf("received unexpected third event %+v (kernel.event.message matches neither filter)", ev) + case <-time.After(50 * time.Millisecond): + } +} + +func TestBus_subscribeFilters_overlappingFiltersDeliverOnce(t *testing.T) { + t.Parallel() + + b := New() + t.Cleanup(func() { _ = b.Close() }) + + got := make(chan Event, 4) + // "kernel.*" and "kernel.event.*" both match "kernel.event.tool_call" — + // a single Subscription registered under both MUST still be invoked + // exactly once per Publish, not once per matching filter. + sub, err := b.SubscribeFilters(context.Background(), []string{"kernel.*", "kernel.event.*"}, func(_ context.Context, ev Event) { + got <- ev + }) + if err != nil { + t.Fatalf("SubscribeFilters: %v", err) + } + t.Cleanup(func() { _ = sub.Close() }) + + if err := b.Publish(context.Background(), Event{Topic: "kernel.event.tool_call"}); err != nil { + t.Fatalf("Publish: %v", err) + } + + recvOrTimeout(t, got) + select { + case ev := <-got: + t.Fatalf("received a second delivery of the same Publish call: %+v", ev) + case <-time.After(50 * time.Millisecond): + } +} + +func TestBus_subscribeFilters_validation(t *testing.T) { + t.Parallel() + + b := New() + t.Cleanup(func() { _ = b.Close() }) + + if _, err := b.SubscribeFilters(context.Background(), nil, func(context.Context, Event) {}); !errors.Is(err, ErrEmptyTopic) { + t.Errorf("SubscribeFilters(nil filters) = %v, want ErrEmptyTopic", err) + } + if _, err := b.SubscribeFilters(context.Background(), []string{"a", ""}, func(context.Context, Event) {}); !errors.Is(err, ErrEmptyTopic) { + t.Errorf("SubscribeFilters(one empty filter) = %v, want ErrEmptyTopic", err) + } + if _, err := b.SubscribeFilters(context.Background(), []string{"a.*"}, nil); !errors.Is(err, ErrNilHandler) { + t.Errorf("SubscribeFilters(nil handler) = %v, want ErrNilHandler", err) + } +} + +func TestBus_remove_prunesWildcardEntries(t *testing.T) { + t.Parallel() + + b := New() + t.Cleanup(func() { _ = b.Close() }) + + sub, err := b.SubscribeFilters(context.Background(), []string{"plugin.tool.github.*"}, func(context.Context, Event) {}) + if err != nil { + t.Fatalf("SubscribeFilters: %v", err) + } + if len(b.wildcards) != 1 { + t.Fatalf("b.wildcards has %d entries after Subscribe, want 1", len(b.wildcards)) + } + + if err := sub.Close(); err != nil { + t.Fatalf("sub.Close: %v", err) + } + + b.mu.RLock() + remaining := len(b.wildcards) + b.mu.RUnlock() + if remaining != 0 { + t.Fatalf("b.wildcards has %d entries after Close, want 0", remaining) + } +} diff --git a/internal/eventbus/filter.go b/internal/eventbus/filter.go new file mode 100644 index 0000000..cbc7523 --- /dev/null +++ b/internal/eventbus/filter.go @@ -0,0 +1,42 @@ +package eventbus + +import "strings" + +// wildcardSuffix is the one wildcard form this package recognizes: a +// filter ending in "*" matches every topic sharing the filter's prefix +// (docs/specifications/event-bus.md#filter-grammar). No mid-string or +// multi-segment wildcard exists — a "*" appearing anywhere but the very +// end of a filter is not treated specially at all, it's simply part of +// the filter string an exact-match lookup would have to hit literally +// (which, in practice, no real topic ever will, since real topics never +// contain "*" — see event-bus.md's topic grammar). +const wildcardSuffix = "*" + +// isWildcardFilter reports whether filter is a trailing-wildcard prefix +// filter (ends in "*") rather than an exact-topic filter. This package +// takes the permissive reading — any trailing "*" counts, not only one +// immediately preceded by "." — leaving the stricter wire-level grammar +// (event-bus.md's "ending in .*", whole segments only) to be validated by +// the RPC boundary that constructs filters from a wire SubscribeRequest, +// not by this generic pub/sub primitive. +func isWildcardFilter(filter string) bool { + return strings.HasSuffix(filter, wildcardSuffix) +} + +// wildcardPrefix returns the prefix a wildcard filter matches against — +// filter with its trailing "*" removed. Only meaningful when +// isWildcardFilter(filter) is true. +func wildcardPrefix(filter string) string { + return strings.TrimSuffix(filter, wildcardSuffix) +} + +// matchesFilter reports whether topic satisfies filter — either an exact +// string match, or, for a wildcard filter, a prefix match against +// wildcardPrefix(filter). A bare "*" filter (prefix "") matches every +// topic. +func matchesFilter(topic, filter string) bool { + if isWildcardFilter(filter) { + return strings.HasPrefix(topic, wildcardPrefix(filter)) + } + return topic == filter +} diff --git a/internal/eventbus/filter_test.go b/internal/eventbus/filter_test.go new file mode 100644 index 0000000..d827eb7 --- /dev/null +++ b/internal/eventbus/filter_test.go @@ -0,0 +1,103 @@ +package eventbus + +import "testing" + +func TestIsWildcardFilter(t *testing.T) { + t.Parallel() + + tests := []struct { + filter string + want bool + }{ + {"plugin.tool.github.*", true}, + {"kernel.*", true}, + {"*", true}, + {"plugin.tool.github.file_changed", false}, + {"", false}, + } + for _, tt := range tests { + if got := isWildcardFilter(tt.filter); got != tt.want { + t.Errorf("isWildcardFilter(%q) = %v, want %v", tt.filter, got, tt.want) + } + } +} + +func TestWildcardPrefix(t *testing.T) { + t.Parallel() + + tests := []struct { + filter string + want string + }{ + {"plugin.tool.github.*", "plugin.tool.github."}, + {"kernel.*", "kernel."}, + {"*", ""}, + } + for _, tt := range tests { + if got := wildcardPrefix(tt.filter); got != tt.want { + t.Errorf("wildcardPrefix(%q) = %q, want %q", tt.filter, got, tt.want) + } + } +} + +func TestMatchesFilter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + topic string + filter string + want bool + }{ + { + name: "exact match", + topic: "plugin.tool.github.file_changed", + filter: "plugin.tool.github.file_changed", + want: true, + }, + { + name: "exact mismatch", + topic: "plugin.tool.github.file_changed", + filter: "plugin.tool.github.pr_opened", + want: false, + }, + { + name: "wildcard matches same-plugin topic", + topic: "plugin.tool.github.file_changed", + filter: "plugin.tool.github.*", + want: true, + }, + { + name: "wildcard does not match a different plugin under the same category", + topic: "plugin.tool.gitlab.file_changed", + filter: "plugin.tool.github.*", + want: false, + }, + { + name: "wildcard does not match the bare prefix itself", + topic: "plugin.tool.github", + filter: "plugin.tool.github.*", + want: false, + }, + { + name: "kernel namespace wildcard", + topic: "kernel.event.tool_call", + filter: "kernel.*", + want: true, + }, + { + name: "bare wildcard matches everything", + topic: "anything.at.all", + filter: "*", + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := matchesFilter(tt.topic, tt.filter); got != tt.want { + t.Errorf("matchesFilter(%q, %q) = %v, want %v", tt.topic, tt.filter, got, tt.want) + } + }) + } +} diff --git a/internal/eventbus/subscription.go b/internal/eventbus/subscription.go index 7840ed4..3967164 100644 --- a/internal/eventbus/subscription.go +++ b/internal/eventbus/subscription.go @@ -17,12 +17,13 @@ import ( // ctx. type Handler func(ctx context.Context, event Event) -// Subscription is one open registration on a Bus: a Topic, a Handler, and -// the unbounded queue plus delivery goroutine that feeds it. The zero -// value is not usable — obtain a Subscription from Bus.Subscribe. +// Subscription is one open registration on a Bus: one or more topic +// filters, a Handler, and the unbounded queue plus delivery goroutine that +// feeds it. The zero value is not usable — obtain a Subscription from +// Bus.Subscribe or Bus.SubscribeFilters. type Subscription struct { bus *Bus - topic string + filters []string handler Handler ctx context.Context @@ -43,11 +44,11 @@ type Subscription struct { // that — so a caller can finish registering the Subscription in bus's // registry before its delivery goroutine can possibly call back into // bus.remove (see Bus.Subscribe's comment on why that ordering matters). -func newSubscription(ctx context.Context, bus *Bus, topic string, handler Handler, logger *slog.Logger, prov *telemetry.Provider, queueWarnThreshold int) *Subscription { +func newSubscription(ctx context.Context, bus *Bus, filters []string, handler Handler, logger *slog.Logger, prov *telemetry.Provider, queueWarnThreshold int) *Subscription { subCtx, cancel := context.WithCancel(ctx) return &Subscription{ bus: bus, - topic: topic, + filters: filters, handler: handler, ctx: subCtx, cancel: cancel, @@ -79,7 +80,7 @@ func (s *Subscription) enqueue(ctx context.Context, event Event) { if depth := s.queue.len(); depth >= s.queueWarnThreshold { s.warned = true s.logger.WarnContext(ctx, "eventbus: subscriber queue depth crossed warn threshold", - "topic", s.topic, "depth", depth, "threshold", s.queueWarnThreshold) + "filters", s.filters, "depth", depth, "threshold", s.queueWarnThreshold) } } @@ -130,7 +131,7 @@ func (s *Subscription) deliverLoop() { func (s *Subscription) invoke(event Event) { defer func() { if r := recover(); r != nil { - s.logger.ErrorContext(s.ctx, "eventbus: subscriber handler panicked", "topic", s.topic, "panic", r) + s.logger.ErrorContext(s.ctx, "eventbus: subscriber handler panicked", "filters", s.filters, "panic", r) } }() diff --git a/internal/kernelcallback/CLAUDE.md b/internal/kernelcallback/CLAUDE.md index 19d28b8..efec2bf 100644 --- a/internal/kernelcallback/CLAUDE.md +++ b/internal/kernelcallback/CLAUDE.md @@ -9,36 +9,90 @@ (`kernel-callbacks.md` §4/§5 — "server-derived, never client-supplied"). Binding identity into the `Server` value at construction makes that property structurally true instead of relying on every interceptor call - site getting it right. Don't refactor this into a shared singleton + - interceptor "for efficiency" — a `Server` value is cheap, and the - future plugin-runtime broker wiring is expected to construct one per - launched plugin, not reuse one across plugins. -- **`RunSession`/`CountTokens`/`Emit` are tracked stubs, not something to - fill in opportunistically.** Each returns `codes.Unimplemented` with its - own message. Do not implement real logic for any of the three here - without a separate task scoped to that RPC's actual semantics - (`agent-loop.md` §7 for `RunSession`; `kernel-callbacks.md` §2/§3 for - `CountTokens`, including the single canonical fallback token-count - formula per `.claude/rules/determinism.md` — don't let a "quick" - `CountTokens` stub grow a second formula; `kernel-callbacks.md` §4 for - `Emit`, including that the kernel is the state backend's sole writer per - `state-backend.md` §3, so `Emit`'s eventual implementation does not - belong in this package at all — it belongs wherever the kernel's sqlite - write path lives, called from here). + site getting it right. This now extends to every other per-plugin + dependency `Config` carries (`Telemetry`, `TelemetryRelay`, `Bus`, + `ResolvedConfig`) — none of them are shared across plugin instances + either, for the identical reason. Don't refactor this into a shared + singleton + interceptor "for efficiency" — a `Server` value is cheap, + and the future plugin-runtime broker wiring is expected to construct one + per launched plugin, not reuse one across plugins. + +- **`RunSession`/`CountTokens`/`Emit`/`ReadEvents`/`GetSession` are + tracked stubs, not something to fill in opportunistically** — but for + two different reasons, not one: + - `RunSession` (`agent-loop.md` §7) and `CountTokens` + (`kernel-callbacks.md` §2/§3, including the single canonical fallback + token-count formula per `.claude/rules/determinism.md` — don't let a + "quick" stub grow a second formula) are blocked on packages that don't + exist yet. + - `Emit` (`kernel-callbacks.md` §4), `ReadEvents`, and `GetSession` are + blocked on something narrower and more specific: nothing anywhere in + this codebase tracks which session(s) a given plugin instance is + authorized to touch. `internal/statebackend.Store.Open` already gives + a working data-read path (confirmed by direct check before writing + `ReadEvents`/`GetSession`'s stubs) — the missing piece is purely the + authorization check kernel-callbacks.md's own MUST requires ("the + kernel MUST reject a call naming any session other than the one the + calling plugin was actually invoked for"). Implementing the data read + without that check would be silently insecure — any plugin could read + any session by guessing or discovering its id — which is worse than + an honest `codes.Unimplemented`. Don't "helpfully" wire these three up + against `Store.Open` directly without also building that + authorization mechanism first; that's new, separately-scoped work + (probably wherever `Emit`'s own implementation eventually lands, since + it needs the identical check). + - `Emit`'s eventual implementation does not belong in this package at + all regardless — it belongs wherever the kernel's sqlite write path + lives, called from here, per `state-backend.md` §3's sole-writer rule. + - **`internal/log.Server` is intentionally untouched by this package.** `Server.Log` here does exactly two things: inject this instance's fixed producer via `producer.WithProducer`, then call straight through to the wrapped `log.Server.Log`. Don't duplicate any of `internal/log`'s entry validation, level translation, or attribute-building logic here — it already lives in exactly one place. + - **`Server` embeds `kernelv1.UnimplementedKernelCallbackServiceServer` by value** (per the generated type's own doc comment, to avoid a nil-pointer dereference) — this is what satisfies `mustEmbedUnimplementedKernelCallbackServiceServer()` - and keeps `Server` forward-compatible if the proto ever adds a fifth RPC. + and keeps `Server` forward-compatible if the proto ever adds another RPC. The embed is a compile-time forward-compatibility guard only; every method the interface currently declares is still explicitly implemented - on `Server` (three as stubs, one as a real delegation) rather than left - to fall through to the embedded unimplemented methods, so - `go vet`/interface satisfaction doesn't silently hide a missing method - later. + on `Server` (five as stubs, seven with real logic) rather than left to + fall through to the embedded unimplemented methods, so `go vet`/interface + satisfaction doesn't silently hide a missing method later. + +- **`Publish`/`Subscribe`'s topic construction and `RecordMetrics`' + instrument-name construction share one helper, `producerScopedName` + (`telemetry.go`), and one lowercase category-text table, `categoryTextTable` + (`category.go`).** `category.go`'s table is a deliberate, independent + copy of `internal/statebackend`'s own `producerCategoryText` — not an + import of it. The two happen to agree on every value today, but they're + conceptually owned by different specs (state-backend.md's storage + encoding vs. event-bus.md's wire-facing topic grammar); don't "simplify" + by importing `internal/statebackend` into this package just to + deduplicate seven map entries. + +- **`Subscribe`'s bounded bridge (`eventbus.go`) is a second, additional + bound layered on top of `internal/eventbus`'s own unbounded, never-drop + contract — it does not change that contract.** The bridge's `events` + channel (capacity `Server.busSubscribeQueueBound`) sits between + `internal/eventbus`'s own delivery goroutine (which still never blocks + or drops) and the gRPC stream's `Send` calls. When `events` is full, the + handler signals `overflow` (buffered 1, non-blocking) instead of + blocking; the main select loop only observes that signal once its + current, possibly slow, `stream.Send` call returns — so don't expect + the stream to close *immediately* on overflow if a `Send` call happens + to be in flight when the bound is exceeded. `TestServer_Subscribe_backpressureCloses` + exercises this exact sequencing (close the test's blocking `release` + channel *before* waiting on `done`, not after — the same + close-before-wait ordering `internal/eventbus/CLAUDE.md` already + documents biting that package's own first test draft). + +- **`GetConfig`'s handler never logs `req` or its own return value.** + `kernel-callbacks.md`'s GetConfig section restates the MUST NOT-echo + rule other RPCs already carry, specifically because `GetConfig` is a + second channel a `sensitive`-marked config value can cross. Don't add an + entry-level log line that includes the resolved config Struct, even at + `TRACE` — see `config.go`'s own comment. diff --git a/internal/kernelcallback/README.md b/internal/kernelcallback/README.md index 0f5e02f..55e027c 100644 --- a/internal/kernelcallback/README.md +++ b/internal/kernelcallback/README.md @@ -1,45 +1,66 @@ # internal/kernelcallback -The composed `kernelv1.KernelCallbackServiceServer` — the four-method +The composed `kernelv1.KernelCallbackServiceServer` — the twelve-method plugin-to-kernel callback service (`RunSession`, `CountTokens`, `Emit`, -`Log`) described in `specifications/kernel-callbacks.md` §1. Every plugin -subprocess, regardless of category, is handed a client connection to this -service at handshake time; `Server` here is the kernel-side implementation -that connection talks to. +`Log`, `ExportSpans`, `RecordMetrics`, `GetTelemetryConfig`, `GetConfig`, +`Publish`, `Subscribe`, `ReadEvents`, `GetSession`) described in +`specifications/kernel-callbacks.md`. Every plugin subprocess, regardless +of category, is handed a client connection to this service at handshake +time; `Server` here is the kernel-side implementation that connection +talks to. ## What this package does -- Delegates `Log` to `internal/log.Server`, which already implements the - full `Log` RPC (entry validation, level translation, session/producer - attribution). `internal/log` is unchanged by this package — it never even - needs to know a composed server exists. -- Stubs `RunSession`, `CountTokens`, and `Emit`, each returning - `codes.Unimplemented`. These aren't oversights: the packages that carry - out their real semantics (`agent-loop.md` §7 for `RunSession`, - `kernel-callbacks.md` §2/§3 for `CountTokens`, `kernel-callbacks.md` §4 - for `Emit`) don't exist yet. Embedding +- **`Log`** (`server.go`) delegates to `internal/log.Server`, which + implements the full RPC (batch entry validation, level translation, + session/producer attribution). `internal/log` is unchanged by this + package. +- **`ExportSpans`/`RecordMetrics`/`GetTelemetryConfig`** (`telemetry.go`) + relay a plugin's spans to the operator's collector via + `internal/telemetryrelay`, record a plugin's metric observations against + kernel-owned dynamic instruments via `internal/telemetry.RecordDynamicMetric`, + and report the operator's configured tracing/metrics/logs signal state — + see `specifications/observability.md` for why spans relay transparently + while metrics deliberately don't. +- **`GetConfig`** (`config.go`) returns the calling plugin's own + already-decoded `agent.hcl` configuration, fixed on the `Server` at + construction. +- **`Publish`/`Subscribe`** (`eventbus.go`) bridge to `internal/eventbus`: + `Publish` constructs a server-derived topic and republishes onto it; + `Subscribe` is a server-streaming RPC layering a per-stream backpressure + bound on top of `internal/eventbus`'s own unbounded, never-drop + contract — see `specifications/event-bus.md#backpressure` for why that + bound exists at this layer and not in `internal/eventbus` itself. +- Stubs `RunSession`, `CountTokens`, `Emit`, `ReadEvents`, and `GetSession`, + each returning `codes.Unimplemented`. `RunSession`/`CountTokens` await + packages that don't exist yet (`agent-loop.md` §7, `kernel-callbacks.md` + §2/§3). `Emit`/`ReadEvents`/`GetSession` are different: their data paths + already exist (`internal/statebackend`), but nothing in this codebase + yet tracks which session(s) a given plugin instance is authorized to + touch — implementing the data read without that check would be silently + insecure, not just incomplete, so they stay honest stubs. Embedding `kernelv1.UnimplementedKernelCallbackServiceServer` would already give `codes.Unimplemented` for free, but this package defines its own stub - methods with package-specific error messages so a caller sees - `kernelcallback: RunSession not implemented`, not a generic proto-gen - message. + methods with package-specific error messages. -## Producer identity is per-instance, not per-call +## Every dependency is per-instance, not per-call -`kernel-callbacks.md` §4/§5 require producer attribution to be -server-derived: a property of which plugin's broker connection a call -arrived on, established once at handshake, never a field the calling -plugin supplies on the request. This package expresses that by binding one -`Server` instance to exactly one plugin's `*commonv1.ProducerRef` at -construction time (`NewServer`). Every RPC that instance serves — today -just `Log`, later all four — uses that same fixed identity. There is no -shared server instance juggling multiple plugins' identities and no -interceptor threading identity onto the context from outside; the identity -lives in the `Server` value itself. +`kernel-callbacks.md` requires producer attribution to be server-derived: +a property of which plugin's broker connection a call arrived on, +established once at handshake, never a field the calling plugin supplies +on the request. This package expresses that by binding one `Server` +instance to exactly one plugin's dependencies at construction time +(`NewServer(Config)`) — not just `Producer`, but also `Telemetry`, +`TelemetryRelay`, `Bus`, and `ResolvedConfig`. Every RPC that instance +serves uses those same fixed values. There is no shared server instance +juggling multiple plugins' identities and no interceptor threading +identity onto the context from outside; the identity and every other +dependency live in the `Server` value itself. ## How this fits in A follow-up task wires `Server` onto the plugin-runtime's callback broker (the `hashicorp/go-plugin` bidirectional connection handed to each launched -plugin subprocess) — that wiring doesn't exist yet, so don't look for it -here. +plugin subprocess) as part of real plugin launch — today only +`internal/pluginruntime`'s integration test fixture constructs one, as a +stand-in for that future caller. diff --git a/internal/kernelcallback/category.go b/internal/kernelcallback/category.go new file mode 100644 index 0000000..9793111 --- /dev/null +++ b/internal/kernelcallback/category.go @@ -0,0 +1,37 @@ +package kernelcallback + +import commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + +// categoryTextTable maps a plugin Category to the lowercase text used when +// building a server-derived, dot-separated name (an event-bus topic via +// Publish, or a RecordMetrics instrument name) — the same lowercase +// vocabulary internal/statebackend's own producerCategoryText uses for its +// stored column values, kept as an independent copy here rather than an +// import: statebackend's map is that package's own storage-encoding detail +// (its own comment notes state-backend.md leaves the column's exact text +// undocumented), while this package's use is a wire-facing protocol detail +// event-bus.md documents generically as "category" — the two happen to +// agree today but are conceptually owned by different specs. +// CATEGORY_UNSPECIFIED is deliberately absent — a producer's category MUST +// NOT ever be unspecified (kernel-callbacks.md's server-derived producer +// identity is always a real category). +var categoryTextTable = map[commonv1.Category]string{ + commonv1.Category_CATEGORY_MODEL: "model", + commonv1.Category_CATEGORY_TOOL: "tool", + commonv1.Category_CATEGORY_CONTEXT: "context", + commonv1.Category_CATEGORY_MEMORY: "memory", + commonv1.Category_CATEGORY_FRONTEND: "frontend", + commonv1.Category_CATEGORY_WIDGET: "widget", + commonv1.Category_CATEGORY_SLASHCOMMAND: "slashcommand", +} + +// categoryText renders category as its lowercase text form. An +// unrecognized or unspecified category (which should never occur — see +// categoryTextTable's comment) renders as "unspecified" rather than +// panicking or producing an empty path segment. +func categoryText(category commonv1.Category) string { + if text, ok := categoryTextTable[category]; ok { + return text + } + return "unspecified" +} diff --git a/internal/kernelcallback/config.go b/internal/kernelcallback/config.go new file mode 100644 index 0000000..b60d74b --- /dev/null +++ b/internal/kernelcallback/config.go @@ -0,0 +1,37 @@ +package kernelcallback + +import ( + "context" + + "github.com/pluggableharness/agent/internal/telemetry" + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + + "google.golang.org/protobuf/types/known/structpb" +) + +// GetConfig implements the GetConfig RPC (kernel-callbacks.md's GetConfig): +// returns the calling plugin's own already-decoded agent.hcl configuration +// — s.resolvedConfig, fixed at construction like every other dependency on +// this Server. A nil s.resolvedConfig (no caller has supplied one yet — +// see Config.ResolvedConfig's doc comment) returns an empty Struct rather +// than an error, since "no config" and "empty config" are indistinguishable +// to a caller either way. +// +// This handler's own logging deliberately never includes req or the +// returned config's contents — GetConfig is a second channel a sensitive +// config value can cross (kernel-callbacks.md's GetConfig: "a plugin MUST +// NOT echo any value from config into Emit, Publish, Render, a log line, +// or an error message"), and logging the value here would defeat that +// rule before the plugin ever gets the chance to violate it itself. +func (s *Server) GetConfig(ctx context.Context, _ *kernelv1.GetConfigRequest) (*kernelv1.GetConfigResult, error) { + ctx, span := s.telemetry.StartKernelCallbackGetConfig(ctx, s.producer) + defer func() { telemetry.EndSpan(span, nil) }() + + s.logger.DebugContext(ctx, "kernelcallback: get_config") + + cfg := s.resolvedConfig + if cfg == nil { + cfg = &structpb.Struct{} + } + return &kernelv1.GetConfigResult{Config: cfg}, nil +} diff --git a/internal/kernelcallback/config_test.go b/internal/kernelcallback/config_test.go new file mode 100644 index 0000000..5169a93 --- /dev/null +++ b/internal/kernelcallback/config_test.go @@ -0,0 +1,45 @@ +package kernelcallback + +import ( + "testing" + + "google.golang.org/protobuf/types/known/structpb" + + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" +) + +func TestServer_GetConfig_returnsResolvedConfig(t *testing.T) { + t.Parallel() + + want, err := structpb.NewStruct(map[string]any{"api_key_ref": "resolved", "timeout_ms": float64(5000)}) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + f := newTestServer(t, testProducer(), func(cfg *Config) { + cfg.ResolvedConfig = want + }) + + got, err := f.server.GetConfig(t.Context(), &kernelv1.GetConfigRequest{}) + if err != nil { + t.Fatalf("GetConfig: %v", err) + } + if got.GetConfig().GetFields()["timeout_ms"].GetNumberValue() != 5000 { + t.Errorf("GetConfig().Config = %v, want the fixture's resolved config", got.GetConfig()) + } +} + +func TestServer_GetConfig_nilResolvedConfigReturnsEmptyStruct(t *testing.T) { + t.Parallel() + f := newTestServer(t, testProducer()) + + got, err := f.server.GetConfig(t.Context(), &kernelv1.GetConfigRequest{}) + if err != nil { + t.Fatalf("GetConfig: %v", err) + } + if got.GetConfig() == nil { + t.Fatal("GetConfig().Config is nil, want an empty (non-nil) Struct") + } + if len(got.GetConfig().GetFields()) != 0 { + t.Errorf("GetConfig().Config = %v, want empty", got.GetConfig()) + } +} diff --git a/internal/kernelcallback/doc.go b/internal/kernelcallback/doc.go index 8d205b8..b92f04a 100644 --- a/internal/kernelcallback/doc.go +++ b/internal/kernelcallback/doc.go @@ -1,21 +1,38 @@ -// Package kernelcallback composes the full four-method +// Package kernelcallback composes the full twelve-method // kernelv1.KernelCallbackServiceServer described in -// specifications/kernel-callbacks.md §1 (RunSession, CountTokens, Emit, -// Log) — the plugin-to-kernel callback channel every plugin subprocess is -// handed at handshake, regardless of category. +// specifications/kernel-callbacks.md (RunSession, CountTokens, Emit, Log, +// ExportSpans, RecordMetrics, GetTelemetryConfig, GetConfig, Publish, +// Subscribe, ReadEvents, GetSession) — the plugin-to-kernel callback +// channel every plugin subprocess is handed at handshake, regardless of +// category. // // Server delegates Log to internal/log.Server, which already implements -// that one RPC. RunSession, CountTokens, and Emit are not yet implemented; -// they return codes.Unimplemented until the packages that carry out their -// semantics (agent-loop.md §7 for RunSession, kernel-callbacks.md §2/§3 for -// CountTokens, kernel-callbacks.md §4 for Emit) exist. +// that one RPC, and implements ExportSpans/RecordMetrics/GetTelemetryConfig +// (telemetry.go), GetConfig (config.go), and Publish/Subscribe +// (eventbus.go) directly against internal/telemetry, internal/telemetryrelay, +// and internal/eventbus. RunSession and CountTokens are not yet +// implemented; they return codes.Unimplemented until the packages that +// carry out their semantics (agent-loop.md §7 for RunSession, +// kernel-callbacks.md §2/§3 for CountTokens) exist. Emit, ReadEvents, and +// GetSession are likewise stubbed — not for a missing data path (Emit's +// target, internal/statebackend, and ReadEvents/GetSession's +// Store.Open-based read path both already exist) but because nothing +// anywhere in this codebase yet tracks which session(s) a given plugin +// instance is authorized to touch, and kernel-callbacks.md's own MUST — +// "the kernel MUST reject a call naming any session other than the one +// the calling plugin was actually invoked for" — has no enforcement +// mechanism to call into without it. Implementing any of the three +// without that check would be silently insecure, not merely incomplete. // // Every Server instance is dedicated to exactly one launched plugin, with -// that plugin's producer identity fixed in at construction time. -// kernel-callbacks.md §4 and §5 both require producer attribution to be -// server-derived — a property of which plugin's broker connection a call -// arrived on, established at handshake — never a client-supplied request -// field. Binding the identity per Server instance, rather than reading it -// from an untrusted request or a shared mutable field, is how this package -// upholds that requirement. +// that plugin's producer identity — and, as of this revision, every other +// per-plugin dependency (telemetry, the event bus, resolved config) — +// fixed in at construction time via Config. kernel-callbacks.md requires +// producer attribution to be server-derived — a property of which +// plugin's broker connection a call arrived on, established at handshake +// — never a client-supplied request field. Binding every dependency per +// Server instance, rather than reading identity from an untrusted request +// or a shared mutable field, is how this package upholds that requirement +// uniformly across all twelve RPCs, not just the ones that touch identity +// directly. package kernelcallback diff --git a/internal/kernelcallback/eventbus.go b/internal/kernelcallback/eventbus.go new file mode 100644 index 0000000..a7a865b --- /dev/null +++ b/internal/kernelcallback/eventbus.go @@ -0,0 +1,184 @@ +package kernelcallback + +import ( + "context" + "fmt" + "strings" + + "github.com/pluggableharness/agent/internal/eventbus" + "github.com/pluggableharness/agent/internal/telemetry" + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// validateEventType reports whether eventType is a valid PublishRequest.event_type: +// a single, non-empty, dot-free, wildcard-free segment +// (kernel-callbacks.md's Publish, event-bus.md's topic grammar). +func validateEventType(eventType string) error { + if eventType == "" { + return fmt.Errorf("event_type is required") + } + if strings.ContainsAny(eventType, ".*") { + return fmt.Errorf("event_type %q must not contain \".\" or \"*\"", eventType) + } + return nil +} + +// validateTopicFilter reports whether filter is a valid SubscribeRequest +// topic_filters entry per event-bus.md's filter grammar: an exact topic +// (no "*" anywhere), or a trailing wildcard where "*" is the filter's last +// character and either the whole filter ("*", matching everything) or +// immediately preceded by "." (a whole-segment wildcard, e.g. "kernel.*"). +// This is deliberately stricter than internal/eventbus's own +// isWildcardFilter (any trailing "*"), which is a generic pub/sub +// primitive that doesn't police this wire-level grammar itself — see that +// package's filter.go doc comment. +func validateTopicFilter(filter string) error { + if filter == "" { + return fmt.Errorf("topic filter must not be empty") + } + idx := strings.IndexByte(filter, '*') + if idx == -1 { + return nil // an exact filter, no wildcard at all. + } + if idx != len(filter)-1 { + return fmt.Errorf("topic filter %q: \"*\" is only valid as the filter's last character", filter) + } + if idx > 0 && filter[idx-1] != '.' { + return fmt.Errorf("topic filter %q: a wildcard must be the bare filter \"*\" or immediately preceded by \".\"", filter) + } + return nil +} + +// Publish implements the Publish RPC (kernel-callbacks.md's Publish): puts +// one event onto the event bus under a topic the kernel constructs from +// s.producer's server-derived identity — a plugin never supplies its own +// topic (event-bus.md#topic-grammar). +func (s *Server) Publish(ctx context.Context, req *kernelv1.PublishRequest) (*kernelv1.PublishResult, error) { + ctx, span := s.telemetry.StartKernelCallbackPublish(ctx, s.producer) + var err error + defer func() { telemetry.EndSpan(span, err) }() + + s.logger.DebugContext(ctx, "kernelcallback: publish", "event_type", req.GetEventType()) + + if validateErr := validateEventType(req.GetEventType()); validateErr != nil { + err = status.Error(codes.InvalidArgument, "kernelcallback: publish: "+validateErr.Error()) + s.logger.WarnContext(ctx, "kernelcallback: publish: rejected", "err", err) + return nil, err + } + if req.GetPayloadType() == "" { + err = status.Error(codes.InvalidArgument, "kernelcallback: publish: payload_type is required") + s.logger.WarnContext(ctx, "kernelcallback: publish: rejected", "err", err) + return nil, err + } + if req.GetSchemaVersion() == "" { + err = status.Error(codes.InvalidArgument, "kernelcallback: publish: schema_version is required") + s.logger.WarnContext(ctx, "kernelcallback: publish: rejected", "err", err) + return nil, err + } + + topic := producerScopedName(s.producer, req.GetEventType()) + busEvent := &kernelv1.BusEvent{ + Topic: topic, + Payload: req.GetPayload(), + PayloadType: req.GetPayloadType(), + SchemaVersion: req.GetSchemaVersion(), + Time: timestamppb.Now(), + } + + if pubErr := s.bus.Publish(ctx, eventbus.Event{Topic: topic, Payload: busEvent}); pubErr != nil { + err = status.Errorf(codes.Internal, "kernelcallback: publish: %v", pubErr) + s.logger.ErrorContext(ctx, "kernelcallback: publish: failed", "err", pubErr) + return nil, err + } + + return &kernelv1.PublishResult{Topic: topic}, nil +} + +// Subscribe implements the Subscribe RPC (kernel-callbacks.md's +// Subscribe): a server-streaming subscription to the event bus, filtered +// by topic_filters (event-bus.md#filter-grammar). See +// event-bus.md#backpressure for why this handler unilaterally closes the +// stream — with codes.ResourceExhausted, never silently — once its +// undelivered-event queue exceeds s.busSubscribeQueueBound; the +// underlying internal/eventbus.Bus itself stays unbounded/never-drop, per +// that package's own contract — the bound applies only to this bridge. +func (s *Server) Subscribe(req *kernelv1.SubscribeRequest, stream kernelv1.KernelCallbackService_SubscribeServer) error { + ctx := stream.Context() + ctx, span := s.telemetry.StartKernelCallbackSubscribe(ctx, s.producer) + var err error + defer func() { telemetry.EndSpan(span, err) }() + + filters := req.GetTopicFilters() + s.logger.DebugContext(ctx, "kernelcallback: subscribe", "filters", filters) + + if len(filters) == 0 { + err = status.Error(codes.InvalidArgument, "kernelcallback: subscribe: topic_filters is required and must be non-empty") + s.logger.WarnContext(ctx, "kernelcallback: subscribe: rejected", "err", err) + return err + } + for _, f := range filters { + if validateErr := validateTopicFilter(f); validateErr != nil { + err = status.Error(codes.InvalidArgument, "kernelcallback: subscribe: "+validateErr.Error()) + s.logger.WarnContext(ctx, "kernelcallback: subscribe: rejected", "err", err) + return err + } + } + + // events is the bridge's own bounded buffer, layered on top of + // internal/eventbus's unbounded per-subscriber queue — see the doc + // comment above. overflow is signalled at most once (buffered 1, + // non-blocking send) the first time events fills up. + events := make(chan *kernelv1.BusEvent, s.busSubscribeQueueBound) + overflow := make(chan struct{}, 1) + + handler := func(_ context.Context, ev eventbus.Event) { + busEvent, ok := ev.Payload.(*kernelv1.BusEvent) + if !ok { + // Every Publish path in this package sets exactly this + // Payload shape; a mismatch here would mean some other + // in-process caller is publishing directly onto a + // plugin-facing topic, which nothing in this codebase does. + s.logger.ErrorContext(ctx, "kernelcallback: subscribe: received a non-BusEvent payload, dropping", "topic", ev.Topic) + return + } + select { + case events <- busEvent: + default: + select { + case overflow <- struct{}{}: + default: + } + } + } + + sub, subErr := s.bus.SubscribeFilters(ctx, filters, handler) + if subErr != nil { + err = status.Errorf(codes.Internal, "kernelcallback: subscribe: %v", subErr) + s.logger.ErrorContext(ctx, "kernelcallback: subscribe: failed", "err", subErr) + return err + } + defer func() { _ = sub.Close() }() + + for { + select { + case <-ctx.Done(): + // Ordinary stream close/cancel — not an error + // (.claude/rules/grpc.md's cancellation rule). + return nil + case <-overflow: + s.telemetry.Instruments().EventBusSubscribeStreamsClosed.Add(ctx, 1) + err = status.Error(codes.ResourceExhausted, "kernelcallback: subscribe: stream exceeded its backpressure bound") + s.logger.WarnContext(ctx, "kernelcallback: subscribe: closing slow-consumer stream", "bound", s.busSubscribeQueueBound) + return err + case busEvent := <-events: + if sendErr := stream.Send(busEvent); sendErr != nil { + err = sendErr + return err + } + } + } +} diff --git a/internal/kernelcallback/eventbus_test.go b/internal/kernelcallback/eventbus_test.go new file mode 100644 index 0000000..a9881bf --- /dev/null +++ b/internal/kernelcallback/eventbus_test.go @@ -0,0 +1,235 @@ +package kernelcallback + +import ( + "context" + "sync" + "testing" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" +) + +// fakeSubscribeStream is a hand-written fake of +// kernelv1.KernelCallbackService_SubscribeServer (go-testing.md: fakes, +// not mocking frameworks). sendFunc, when set, is called for every Send; +// otherwise Send records the event and returns nil. +type fakeSubscribeStream struct { + ctx context.Context + sendFunc func(*kernelv1.BusEvent) error + + mu sync.Mutex + sent []*kernelv1.BusEvent +} + +func newFakeSubscribeStream(ctx context.Context) *fakeSubscribeStream { + return &fakeSubscribeStream{ctx: ctx} +} + +func (f *fakeSubscribeStream) Send(ev *kernelv1.BusEvent) error { + if f.sendFunc != nil { + return f.sendFunc(ev) + } + f.mu.Lock() + defer f.mu.Unlock() + f.sent = append(f.sent, ev) + return nil +} + +func (f *fakeSubscribeStream) Sent() []*kernelv1.BusEvent { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]*kernelv1.BusEvent, len(f.sent)) + copy(out, f.sent) + return out +} + +func (f *fakeSubscribeStream) Context() context.Context { return f.ctx } +func (f *fakeSubscribeStream) SetHeader(metadata.MD) error { return nil } +func (f *fakeSubscribeStream) SendHeader(metadata.MD) error { return nil } +func (f *fakeSubscribeStream) SetTrailer(metadata.MD) {} +func (f *fakeSubscribeStream) SendMsg(any) error { return nil } +func (f *fakeSubscribeStream) RecvMsg(any) error { return nil } + +// publishFileChanged publishes one "file_changed" event through +// f.server.Publish — the fixture used by every Subscribe test below. +func publishFileChanged(t *testing.T, f *testFixture) { + t.Helper() + if _, err := f.server.Publish(t.Context(), &kernelv1.PublishRequest{ + EventType: "file_changed", PayloadType: "text/plain", SchemaVersion: "1", + }); err != nil { + t.Fatalf("Publish: %v", err) + } +} + +// waitUntil retries fn (publishing one event before each check) up to 200 +// times, 10ms apart, until condition reports true — a bounded poll rather +// than a fixed sleep, for the inherent race between a goroutine-launched +// Subscribe call reaching internal/eventbus's registration point and this +// test's first Publish. +func waitUntil(t *testing.T, condition func() bool, publish func()) { + t.Helper() + for range 200 { + publish() + if condition() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("timed out waiting for condition") +} + +func TestServer_Publish_constructsTopic(t *testing.T) { + t.Parallel() + f := newTestServer(t, testProducer()) + + result, err := f.server.Publish(t.Context(), &kernelv1.PublishRequest{ + EventType: "file_changed", + Payload: []byte("data"), + PayloadType: "text/plain", + SchemaVersion: "1", + }) + if err != nil { + t.Fatalf("Publish: %v", err) + } + if result.GetTopic() != "plugin.tool.github.file_changed" { + t.Errorf("Publish result topic = %q, want plugin.tool.github.file_changed", result.GetTopic()) + } +} + +func TestServer_Publish_validation(t *testing.T) { + t.Parallel() + f := newTestServer(t, testProducer()) + + tests := []struct { + name string + req *kernelv1.PublishRequest + }{ + {"empty event_type", &kernelv1.PublishRequest{PayloadType: "text/plain", SchemaVersion: "1"}}, + {"event_type contains dot", &kernelv1.PublishRequest{EventType: "a.b", PayloadType: "text/plain", SchemaVersion: "1"}}, + {"event_type contains wildcard", &kernelv1.PublishRequest{EventType: "a*", PayloadType: "text/plain", SchemaVersion: "1"}}, + {"empty payload_type", &kernelv1.PublishRequest{EventType: "x", SchemaVersion: "1"}}, + {"empty schema_version", &kernelv1.PublishRequest{EventType: "x", PayloadType: "text/plain"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := f.server.Publish(t.Context(), tt.req) + assertCode(t, err, codes.InvalidArgument) + }) + } +} + +func TestServer_Subscribe_receivesPublishedEvent(t *testing.T) { + t.Parallel() + f := newTestServer(t, testProducer()) + + ctx, cancel := context.WithCancel(t.Context()) + stream := newFakeSubscribeStream(ctx) + + done := make(chan error, 1) + go func() { + done <- f.server.Subscribe(&kernelv1.SubscribeRequest{TopicFilters: []string{"plugin.tool.github.*"}}, stream) + }() + + waitUntil(t, + func() bool { return len(stream.Sent()) > 0 }, + func() { publishFileChanged(t, f) }, + ) + + cancel() + select { + case err := <-done: + if err != nil { + t.Fatalf("Subscribe returned an error after context cancel: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Subscribe did not return after its context was canceled") + } + + sent := stream.Sent() + if len(sent) == 0 || sent[0].GetTopic() != "plugin.tool.github.file_changed" { + t.Fatalf("Sent() = %+v, want at least one event on plugin.tool.github.file_changed", sent) + } +} + +func TestServer_Subscribe_validation(t *testing.T) { + t.Parallel() + f := newTestServer(t, testProducer()) + stream := newFakeSubscribeStream(t.Context()) + + tests := []struct { + name string + req *kernelv1.SubscribeRequest + }{ + {"empty filters", &kernelv1.SubscribeRequest{}}, + {"mid-string wildcard", &kernelv1.SubscribeRequest{TopicFilters: []string{"plugin.*.github"}}}, + {"wildcard not preceded by dot", &kernelv1.SubscribeRequest{TopicFilters: []string{"plugintool*"}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := f.server.Subscribe(tt.req, stream) + assertCode(t, err, codes.InvalidArgument) + }) + } +} + +func TestServer_Subscribe_backpressureCloses(t *testing.T) { + t.Parallel() + f := newTestServer(t, testProducer(), func(cfg *Config) { cfg.BusSubscribeQueueBound = 1 }) + + release := make(chan struct{}) + sendStarted := make(chan struct{}, 1) + stream := newFakeSubscribeStream(t.Context()) + stream.sendFunc = func(*kernelv1.BusEvent) error { + select { + case sendStarted <- struct{}{}: + default: + } + <-release + return nil + } + + done := make(chan error, 1) + go func() { + done <- f.server.Subscribe(&kernelv1.SubscribeRequest{TopicFilters: []string{"plugin.tool.github.*"}}, stream) + }() + + waitUntil(t, + func() bool { + select { + case <-sendStarted: + return true + default: + return false + } + }, + func() { publishFileChanged(t, f) }, + ) + + // The first Send is now blocked on release. With bound=1, the next + // publish fills the bridge's bounded buffer and the one after that + // overflows it — signalling the (buffered, non-blocking) overflow + // channel, which the main select loop can only observe once its + // current, still-blocked Send call returns. Delivery of these two + // publishes to our handler happens on internal/eventbus's own + // delivery goroutine, asynchronously — Publish returning only means + // the event reached that subscription's queue, not that the handler + // has run yet — so the wait below is bounded generously (not just + // enough for the two, arbitrarily-scheduled deliveries plus Send + // unblocking, but enough to absorb real scheduler contention when the + // full suite runs with -shuffle across many packages in parallel). + publishFileChanged(t, f) + publishFileChanged(t, f) + close(release) + + select { + case err := <-done: + assertCode(t, err, codes.ResourceExhausted) + case <-time.After(10 * time.Second): + t.Fatal("Subscribe did not return after exceeding its backpressure bound") + } +} diff --git a/internal/kernelcallback/server.go b/internal/kernelcallback/server.go index b96f516..c717377 100644 --- a/internal/kernelcallback/server.go +++ b/internal/kernelcallback/server.go @@ -2,35 +2,135 @@ package kernelcallback import ( "context" + "log/slog" + "github.com/pluggableharness/agent/internal/eventbus" "github.com/pluggableharness/agent/internal/log" "github.com/pluggableharness/agent/internal/producer" + "github.com/pluggableharness/agent/internal/telemetry" + "github.com/pluggableharness/agent/internal/telemetryrelay" commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + logv1 "github.com/pluggableharness/agent/pkg/log/proto/v1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" ) +// Config bundles every dependency NewServer needs. Every field here is +// fixed once at construction, for the same reason Producer already was +// (see the package doc comment and CLAUDE.md's "one Server per plugin +// instance" note, now extended to cover every dependency added since — +// none of these are shared, mutable, or read from an untrusted request). +type Config struct { + // Log is the wrapped internal/log.Server the Log RPC delegates to. + // MUST be set. + Log *log.Server + + // Producer is this Server's fixed, server-derived producer identity — + // the plugin this instance is dedicated to. MUST be set. + Producer *commonv1.ProducerRef + + // Telemetry is this plugin's telemetry.Provider, used for + // GetTelemetryConfig's reported signal state and for + // RecordMetrics' dynamic per-name instrument recording. MUST be set. + Telemetry *telemetry.Provider + + // TelemetryRelay uploads ExportSpans' relayed batches to the + // configured collector (observability.md#the-relay-model). MUST be + // set. + TelemetryRelay *telemetryrelay.Relay + + // Bus is the event bus Publish/Subscribe operate against + // (event-bus.md). MUST be set. + Bus *eventbus.Bus + + // BusSubscribeQueueBound is the per-Subscribe-stream backpressure + // bound (event-bus.md#backpressure, configuration/blocks-reference.md's + // event_bus.subscribe_queue_bound). A value <= 0 falls back to + // defaultBusSubscribeQueueBound. + BusSubscribeQueueBound int + + // ResolvedConfig is this plugin's already-decoded agent.hcl + // configuration, identical in shape to what its own ConfigureRequest.config + // carried — GetConfig's result. MAY be nil until whatever resolves and + // caches a plugin's config alongside its launch (agent-loop.md, not + // yet built) exists to supply it; GetConfig returns an empty Struct + // rather than erroring when nil, since "no config" and "empty config" + // are indistinguishable to a caller either way. + ResolvedConfig *structpb.Struct + + // LogLevel is the floor GetTelemetryConfig reports — the operator's + // configured settings.log_level (configuration/blocks-reference.md), + // translated to the wire LogLevel enum by whatever loads that config. + // Defaults to LOG_LEVEL_INFO if left LOG_LEVEL_UNSPECIFIED, matching + // blocks-reference.md's own documented default. + LogLevel logv1.LogLevel + + // Logger is this Server's own kernel-native logger, for the + // entry/error instrumentation .claude/rules/logging-telemetry.md + // requires of every gRPC handler — distinct from Log (which relays a + // *plugin's* log output, not this package's own). A nil Logger + // defaults to slog.Default(), matching log.NewServer's own fallback. + Logger *slog.Logger +} + +// defaultBusSubscribeQueueBound is the fallback per-Subscribe-stream +// backpressure bound when Config.BusSubscribeQueueBound is <= 0, matching +// configuration/blocks-reference.md's event_bus.subscribe_queue_bound +// default. +const defaultBusSubscribeQueueBound = 1024 + // Server is the composed kernelv1.KernelCallbackServiceServer handed to // every plugin subprocess over the callback broker (kernel-callbacks.md // §1). One Server instance exists per launched plugin, constructed with -// that plugin's already-resolved producer identity baked in — producer +// that plugin's already-resolved dependencies baked in — producer // attribution is a property of which plugin's broker connection a call // arrived on, established at handshake, and MUST be server-derived, never -// a client-supplied request field (kernel-callbacks.md §4, §5). +// a client-supplied request field (kernel-callbacks.md §4, §5) — the same +// binding-at-construction shape now covers every other dependency added +// since (telemetry, the event bus, resolved config). type Server struct { kernelv1.UnimplementedKernelCallbackServiceServer - log *log.Server - producer *commonv1.ProducerRef + log *log.Server + producer *commonv1.ProducerRef + telemetry *telemetry.Provider + relay *telemetryrelay.Relay + bus *eventbus.Bus + busSubscribeQueueBound int + resolvedConfig *structpb.Struct + logLevel logv1.LogLevel + logger *slog.Logger } -// NewServer returns a Server delegating Log to logServer, with every call's -// server-derived producer identity fixed to producerRef — the plugin this -// Server instance is dedicated to. -func NewServer(logServer *log.Server, producerRef *commonv1.ProducerRef) *Server { - return &Server{log: logServer, producer: producerRef} +// NewServer returns a Server bound to cfg — see Config's field comments +// for what each dependency is used for. +func NewServer(cfg Config) *Server { + bound := cfg.BusSubscribeQueueBound + if bound <= 0 { + bound = defaultBusSubscribeQueueBound + } + logLevel := cfg.LogLevel + if logLevel == logv1.LogLevel_LOG_LEVEL_UNSPECIFIED { + logLevel = logv1.LogLevel_LOG_LEVEL_INFO + } + logger := cfg.Logger + if logger == nil { + logger = slog.Default() + } + return &Server{ + log: cfg.Log, + producer: cfg.Producer, + telemetry: cfg.Telemetry, + relay: cfg.TelemetryRelay, + bus: cfg.Bus, + busSubscribeQueueBound: bound, + resolvedConfig: cfg.ResolvedConfig, + logLevel: logLevel, + logger: logger, + } } // Log implements the Log RPC by injecting this Server's fixed producer @@ -61,3 +161,29 @@ func (s *Server) CountTokens(_ context.Context, _ *kernelv1.CountTokensRequest) func (s *Server) Emit(_ context.Context, _ *kernelv1.EmitRequest) (*kernelv1.EmitResult, error) { return nil, status.Error(codes.Unimplemented, "kernelcallback: Emit not implemented") } + +// ReadEvents is not yet implemented. internal/statebackend.Store.Open +// already gives this package a working data-read path (open a session by +// id, then Session.Events()), but kernel-callbacks.md's own MUST — "the +// kernel MUST reject a call naming any session other than the one the +// calling plugin was actually invoked for" — has no enforcement mechanism +// to call into anywhere in this codebase yet: nothing tracks which +// session(s) a given plugin instance is currently scoped to, the same gap +// that already keeps Emit unimplemented above. Implementing the data read +// without that authorization check would be silently insecure (any +// plugin could read any session's full event log by guessing or +// discovering its id) rather than honestly unimplemented, so this stays a +// stub until that tracking exists — not a partial implementation to "fill +// in opportunistically" (kernelcallback/CLAUDE.md's existing rule for +// RunSession/CountTokens/Emit, extended here for the same reason). +func (s *Server) ReadEvents(_ *kernelv1.ReadEventsRequest, _ kernelv1.KernelCallbackService_ReadEventsServer) error { + return status.Error(codes.Unimplemented, "kernelcallback: ReadEvents not implemented") +} + +// GetSession is not yet implemented, for the identical session- +// authorization gap ReadEvents documents above — GetSession also takes an +// explicit session_id this package cannot yet verify the calling plugin +// was actually invoked for. +func (s *Server) GetSession(_ context.Context, _ *kernelv1.GetSessionRequest) (*kernelv1.GetSessionResult, error) { + return nil, status.Error(codes.Unimplemented, "kernelcallback: GetSession not implemented") +} diff --git a/internal/kernelcallback/server_test.go b/internal/kernelcallback/server_test.go index cc4bcf2..1650b5c 100644 --- a/internal/kernelcallback/server_test.go +++ b/internal/kernelcallback/server_test.go @@ -6,8 +6,12 @@ import ( "testing" "time" + "github.com/pluggableharness/agent/internal/eventbus" "github.com/pluggableharness/agent/internal/log" "github.com/pluggableharness/agent/internal/producer" + "github.com/pluggableharness/agent/internal/telemetry" + "github.com/pluggableharness/agent/internal/telemetry/drivers/fake" + "github.com/pluggableharness/agent/internal/telemetryrelay" commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" logv1 "github.com/pluggableharness/agent/pkg/log/proto/v1" @@ -56,34 +60,91 @@ func validEntry(t *testing.T) *logv1.LogEntry { } } +// testFixture bundles a fully-constructed Server plus every fake its +// dependencies were built from, so a test can assert against whichever +// one its RPC under test actually touches. +type testFixture struct { + server *Server + logHandler *fakeHandler + telemetry *fake.Backend + provider *telemetry.Provider + bus *eventbus.Bus + relayClient *fake.RelayedSpansRecorder +} + +// newTestServer builds a Server with every dependency wired to an +// in-memory fake, overridable via opts (each opt runs against the Config +// before NewServer is called). +func newTestServer(t *testing.T, producerRef *commonv1.ProducerRef, opts ...func(*Config)) *testFixture { + t.Helper() + + logHandler := &fakeHandler{} + logServer := log.NewServer(slog.New(logHandler)) + + telemetryBackend := fake.New() + cfg := telemetry.DefaultConfig + cfg.ServiceName = "test" + prov, err := telemetry.New(context.Background(), cfg, telemetryBackend, nil) + if err != nil { + t.Fatalf("telemetry.New: %v", err) + } + t.Cleanup(func() { + if err := prov.Shutdown(context.Background()); err != nil { + t.Errorf("telemetry Shutdown: %v", err) + } + }) + + relay := telemetryrelay.New(telemetryBackend.RelayedSpans) + bus := eventbus.New() + t.Cleanup(func() { _ = bus.Close() }) + + serverCfg := Config{ + Log: logServer, + Producer: producerRef, + Telemetry: prov, + TelemetryRelay: relay, + Bus: bus, + } + for _, opt := range opts { + opt(&serverCfg) + } + + return &testFixture{ + server: NewServer(serverCfg), + logHandler: logHandler, + telemetry: telemetryBackend, + provider: prov, + bus: bus, + relayClient: telemetryBackend.RelayedSpans, + } +} + func TestServer_Log_delegatesWithServerDerivedProducer(t *testing.T) { t.Parallel() - h := &fakeHandler{} - logServer := log.NewServer(slog.New(h)) want := &commonv1.ProducerRef{ Category: commonv1.Category_CATEGORY_TOOL, Name: "ripgrep", Version: "1.2.3", } - s := NewServer(logServer, want) + f := newTestServer(t, want) // Deliberately no producer.WithProducer on the incoming ctx: Server.Log // must derive attribution from its own baked-in producer, not from // anything already on ctx. - req := &kernelv1.LogRequest{Entry: validEntry(t)} - result, err := s.Log(t.Context(), req) + req := &kernelv1.LogRequest{Entries: []*logv1.LogEntry{validEntry(t)}} + result, err := f.server.Log(t.Context(), req) if err != nil { t.Fatalf("Log: unexpected error: %v", err) } if result == nil { t.Fatal("Log: result is nil") } - if len(h.records) != 1 { - t.Fatalf("handler captured %d records, want 1", len(h.records)) + if len(f.logHandler.records) != 1 { + t.Fatalf("handler captured %d records, want 1", len(f.logHandler.records)) } - attrs := collectAttrs(h.records[0]) + attrs := collectAttrs(f.logHandler.records[0]) if attrs["producer_category"] != want.GetCategory().String() { t.Fatalf("attrs[producer_category] = %v, want %v", attrs["producer_category"], want.GetCategory().String()) } @@ -98,14 +159,12 @@ func TestServer_Log_delegatesWithServerDerivedProducer(t *testing.T) { func TestServer_Log_ignoresContextProducer(t *testing.T) { t.Parallel() - h := &fakeHandler{} - logServer := log.NewServer(slog.New(h)) baked := &commonv1.ProducerRef{ Category: commonv1.Category_CATEGORY_TOOL, Name: "baked-in", Version: "1.0.0", } - s := NewServer(logServer, baked) + f := newTestServer(t, baked) // A different producer already on the incoming ctx MUST be overridden // by the Server's own baked-in identity — attribution is a property of @@ -117,11 +176,11 @@ func TestServer_Log_ignoresContextProducer(t *testing.T) { } ctx := producer.WithProducer(t.Context(), spoofed) - _, err := s.Log(ctx, &kernelv1.LogRequest{Entry: validEntry(t)}) + _, err := f.server.Log(ctx, &kernelv1.LogRequest{Entries: []*logv1.LogEntry{validEntry(t)}}) if err != nil { t.Fatalf("Log: unexpected error: %v", err) } - attrs := collectAttrs(h.records[0]) + attrs := collectAttrs(f.logHandler.records[0]) if attrs["producer_name"] != "baked-in" { t.Fatalf("attrs[producer_name] = %v, want baked-in (server-derived, not ctx-derived)", attrs["producer_name"]) } @@ -130,7 +189,8 @@ func TestServer_Log_ignoresContextProducer(t *testing.T) { func TestServer_unimplementedMethods(t *testing.T) { t.Parallel() - s := NewServer(log.NewServer(slog.New(&fakeHandler{})), &commonv1.ProducerRef{Name: "x"}) + f := newTestServer(t, &commonv1.ProducerRef{Name: "x"}) + s := f.server t.Run("RunSession", func(t *testing.T) { t.Parallel() @@ -149,6 +209,52 @@ func TestServer_unimplementedMethods(t *testing.T) { _, err := s.Emit(t.Context(), &kernelv1.EmitRequest{}) assertUnimplemented(t, err) }) + + t.Run("GetSession", func(t *testing.T) { + t.Parallel() + _, err := s.GetSession(t.Context(), &kernelv1.GetSessionRequest{SessionId: "sess-1"}) + assertUnimplemented(t, err) + }) + + t.Run("ReadEvents", func(t *testing.T) { + t.Parallel() + err := s.ReadEvents(&kernelv1.ReadEventsRequest{SessionId: "sess-1"}, nil) + assertUnimplemented(t, err) + }) +} + +func TestNewServer_defaults(t *testing.T) { + t.Parallel() + + f := newTestServer(t, &commonv1.ProducerRef{Name: "x"}) + s := f.server + + if s.busSubscribeQueueBound != defaultBusSubscribeQueueBound { + t.Errorf("busSubscribeQueueBound = %d, want default %d", s.busSubscribeQueueBound, defaultBusSubscribeQueueBound) + } + if s.logLevel != logv1.LogLevel_LOG_LEVEL_INFO { + t.Errorf("logLevel = %v, want LOG_LEVEL_INFO default", s.logLevel) + } + if s.logger == nil { + t.Error("logger is nil, want slog.Default() fallback") + } +} + +func TestNewServer_explicitOverrides(t *testing.T) { + t.Parallel() + + f := newTestServer(t, &commonv1.ProducerRef{Name: "x"}, func(cfg *Config) { + cfg.BusSubscribeQueueBound = 42 + cfg.LogLevel = logv1.LogLevel_LOG_LEVEL_DEBUG + }) + s := f.server + + if s.busSubscribeQueueBound != 42 { + t.Errorf("busSubscribeQueueBound = %d, want 42", s.busSubscribeQueueBound) + } + if s.logLevel != logv1.LogLevel_LOG_LEVEL_DEBUG { + t.Errorf("logLevel = %v, want LOG_LEVEL_DEBUG", s.logLevel) + } } func assertUnimplemented(t *testing.T, err error) { diff --git a/internal/kernelcallback/telemetry.go b/internal/kernelcallback/telemetry.go new file mode 100644 index 0000000..1dae3a4 --- /dev/null +++ b/internal/kernelcallback/telemetry.go @@ -0,0 +1,137 @@ +package kernelcallback + +import ( + "context" + + "github.com/pluggableharness/agent/internal/telemetry" + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + metricv1 "github.com/pluggableharness/agent/pkg/metric/proto/v1" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// metricKindToDynamic maps the wire metric.v1.MetricKind to this +// process's internal/telemetry.DynamicMetricKind — the two enums are +// deliberately parallel, so this is a direct value-for-value translation. +var metricKindToDynamic = map[metricv1.MetricKind]telemetry.DynamicMetricKind{ + metricv1.MetricKind_METRIC_KIND_COUNTER: telemetry.DynamicMetricKindCounter, + metricv1.MetricKind_METRIC_KIND_UP_DOWN_COUNTER: telemetry.DynamicMetricKindUpDownCounter, + metricv1.MetricKind_METRIC_KIND_HISTOGRAM: telemetry.DynamicMetricKindHistogram, +} + +// ExportSpans implements the ExportSpans RPC (kernel-callbacks.md's +// ExportSpans): relays req.Spans to the operator's configured collector +// via s.relay, unmodified in every identity/timing field +// (observability.md#the-relay-model). Producer attribution comes from +// s.producer — the same server-derived identity every other RPC on this +// service uses — never from a field on the request. +func (s *Server) ExportSpans(ctx context.Context, req *kernelv1.ExportSpansRequest) (*kernelv1.ExportSpansResult, error) { + ctx, span := s.telemetry.StartKernelCallbackExportSpans(ctx, s.producer) + var err error + defer func() { telemetry.EndSpan(span, err) }() + + s.logger.DebugContext(ctx, "kernelcallback: export_spans", "spans", len(req.GetSpans())) + + spans := req.GetSpans() + if len(spans) == 0 { + err = status.Error(codes.InvalidArgument, "kernelcallback: export_spans: spans is required and must be non-empty") + s.logger.WarnContext(ctx, "kernelcallback: export_spans: rejected", "err", err) + return nil, err + } + + if uploadErr := s.relay.Upload(ctx, spans, s.producer); uploadErr != nil { + err = status.Errorf(codes.Internal, "kernelcallback: export_spans: %v", uploadErr) + s.logger.ErrorContext(ctx, "kernelcallback: export_spans: upload failed", "err", uploadErr) + return nil, err + } + + s.telemetry.Instruments().RelayedSpans.Add(ctx, int64(len(spans))) + return &kernelv1.ExportSpansResult{}, nil +} + +// RecordMetrics implements the RecordMetrics RPC (kernel-callbacks.md's +// RecordMetrics): records each observation against a kernel-owned, +// per-name instrument via s.telemetry.RecordDynamicMetric — deliberately +// not a transparent relay, see +// observability.md#the-tracing-metrics-asymmetry. The instrument name is +// built from s.producer's server-derived identity, exactly as Publish +// constructs an event-bus topic from the same identity — a plugin never +// supplies its own instrument namespace. +func (s *Server) RecordMetrics(ctx context.Context, req *kernelv1.RecordMetricsRequest) (*kernelv1.RecordMetricsResult, error) { + ctx, span := s.telemetry.StartKernelCallbackRecordMetrics(ctx, s.producer) + var err error + defer func() { telemetry.EndSpan(span, err) }() + + metrics := req.GetMetrics() + s.logger.DebugContext(ctx, "kernelcallback: record_metrics", "metrics", len(metrics)) + + if len(metrics) == 0 { + err = status.Error(codes.InvalidArgument, "kernelcallback: record_metrics: metrics is required and must be non-empty") + s.logger.WarnContext(ctx, "kernelcallback: record_metrics: rejected", "err", err) + return nil, err + } + + for _, m := range metrics { + kind, ok := metricKindToDynamic[m.GetKind()] + if !ok { + err = status.Errorf(codes.InvalidArgument, "kernelcallback: record_metrics: %q: kind is unspecified or unknown", m.GetName()) + s.logger.WarnContext(ctx, "kernelcallback: record_metrics: rejected", "err", err) + return nil, err + } + + name := producerScopedName(s.producer, m.GetName()) + value := metricValue(m) + if recErr := s.telemetry.RecordDynamicMetric(ctx, name, kind, value, m.GetAttributes()); recErr != nil { + err = status.Errorf(codes.InvalidArgument, "kernelcallback: record_metrics: %v", recErr) + s.logger.WarnContext(ctx, "kernelcallback: record_metrics: rejected", "err", recErr) + return nil, err + } + } + + return &kernelv1.RecordMetricsResult{}, nil +} + +// metricValue extracts m's oneof value as a float64 — internal/telemetry's +// dynamic instruments are Float64-shaped regardless of which wire variant +// was set (dynamicmetric.go's own doc comment explains why). +func metricValue(m *metricv1.MetricRecord) float64 { + if _, ok := m.GetValue().(*metricv1.MetricRecord_DoubleValue); ok { + return m.GetDoubleValue() + } + return float64(m.GetIntValue()) +} + +// GetTelemetryConfig implements the GetTelemetryConfig RPC +// (kernel-callbacks.md's GetTelemetryConfig): reports whether tracing/ +// metrics/logs are enabled and at what level/ratio, read from s.telemetry's +// own Config and s.logLevel, so a plugin doesn't have to guess from its +// own environment. +func (s *Server) GetTelemetryConfig(ctx context.Context, _ *kernelv1.GetTelemetryConfigRequest) (*kernelv1.GetTelemetryConfigResult, error) { + ctx, span := s.telemetry.StartKernelCallbackGetTelemetryConfig(ctx, s.producer) + defer func() { telemetry.EndSpan(span, nil) }() + + s.logger.DebugContext(ctx, "kernelcallback: get_telemetry_config") + + cfg := s.telemetry.Config() + return &kernelv1.GetTelemetryConfigResult{ + TracesEnabled: cfg.TracesEnabled, + MetricsEnabled: cfg.MetricsEnabled, + LogsEnabled: cfg.LogsEnabled, + LogLevel: s.logLevel, + SamplingRatio: cfg.SamplingRatio, + }, nil +} + +// producerScopedName builds a server-derived, dot-separated name from +// producer's identity plus a caller-declared leaf segment — the shared +// construction Publish uses for a bus topic and RecordMetrics uses for an +// instrument name. leaf is used verbatim; validating its shape (no "." or +// "*") is the caller's job (see eventbus.go's validateEventType for +// Publish's stricter version of this same rule) — RecordMetrics' metric +// name has no equivalent wire-level grammar restriction today, so this +// helper does not enforce one here. +func producerScopedName(p *commonv1.ProducerRef, leaf string) string { + return "plugin." + categoryText(p.GetCategory()) + "." + p.GetName() + "." + leaf +} diff --git a/internal/kernelcallback/telemetry_test.go b/internal/kernelcallback/telemetry_test.go new file mode 100644 index 0000000..ef7e746 --- /dev/null +++ b/internal/kernelcallback/telemetry_test.go @@ -0,0 +1,171 @@ +package kernelcallback + +import ( + "testing" + "time" + + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + metricv1 "github.com/pluggableharness/agent/pkg/metric/proto/v1" + tracev1 "github.com/pluggableharness/agent/pkg/trace/proto/v1" +) + +func testProducer() *commonv1.ProducerRef { + return &commonv1.ProducerRef{ + Category: commonv1.Category_CATEGORY_TOOL, + Name: "github", + Version: "1.0.0", + } +} + +func testSpan(t *testing.T) *tracev1.Span { + t.Helper() + return &tracev1.Span{ + TraceId: "0123456789abcdef0123456789abcdef", + SpanId: "fedcba9876543210", + Name: "tool.execute", + Kind: tracev1.SpanKind_SPAN_KIND_INTERNAL, + StartTime: timestamppb.Now(), + EndTime: timestamppb.New(time.Now().Add(time.Second)), + Status: &tracev1.Status{Code: tracev1.StatusCode_STATUS_CODE_OK}, + Scope: &tracev1.InstrumentationScope{Name: "plugin.tool.github"}, + } +} + +func TestServer_ExportSpans(t *testing.T) { + t.Parallel() + f := newTestServer(t, testProducer()) + + _, err := f.server.ExportSpans(t.Context(), &kernelv1.ExportSpansRequest{ + Spans: []*tracev1.Span{testSpan(t)}, + }) + if err != nil { + t.Fatalf("ExportSpans: %v", err) + } + + got := f.relayClient.ResourceSpans() + if len(got) != 1 { + t.Fatalf("relayed ResourceSpans = %d, want 1", len(got)) + } +} + +func TestServer_ExportSpans_emptyRejected(t *testing.T) { + t.Parallel() + f := newTestServer(t, testProducer()) + + _, err := f.server.ExportSpans(t.Context(), &kernelv1.ExportSpansRequest{}) + assertCode(t, err, codes.InvalidArgument) +} + +func TestServer_RecordMetrics_counter(t *testing.T) { + t.Parallel() + f := newTestServer(t, testProducer()) + + req := &kernelv1.RecordMetricsRequest{ + Metrics: []*metricv1.MetricRecord{ + { + Name: "calls", + Kind: metricv1.MetricKind_METRIC_KIND_COUNTER, + Value: &metricv1.MetricRecord_IntValue{IntValue: 3}, + Time: timestamppb.Now(), + }, + }, + } + if _, err := f.server.RecordMetrics(t.Context(), req); err != nil { + t.Fatalf("RecordMetrics: %v", err) + } + + var rm metricdata.ResourceMetrics + if err := f.telemetry.Metrics.Collect(t.Context(), &rm); err != nil { + t.Fatalf("Collect: %v", err) + } + found := false + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + if m.Name == "plugin.tool.github.calls" { + found = true + } + } + } + if !found { + t.Error("plugin.tool.github.calls not found in collected metrics") + } +} + +func TestServer_RecordMetrics_emptyRejected(t *testing.T) { + t.Parallel() + f := newTestServer(t, testProducer()) + + _, err := f.server.RecordMetrics(t.Context(), &kernelv1.RecordMetricsRequest{}) + assertCode(t, err, codes.InvalidArgument) +} + +func TestServer_RecordMetrics_unspecifiedKindRejected(t *testing.T) { + t.Parallel() + f := newTestServer(t, testProducer()) + + req := &kernelv1.RecordMetricsRequest{ + Metrics: []*metricv1.MetricRecord{ + {Name: "x", Kind: metricv1.MetricKind_METRIC_KIND_UNSPECIFIED, Time: timestamppb.Now()}, + }, + } + _, err := f.server.RecordMetrics(t.Context(), req) + assertCode(t, err, codes.InvalidArgument) +} + +func TestServer_RecordMetrics_kindMismatchRejected(t *testing.T) { + t.Parallel() + f := newTestServer(t, testProducer()) + + first := &kernelv1.RecordMetricsRequest{ + Metrics: []*metricv1.MetricRecord{ + {Name: "x", Kind: metricv1.MetricKind_METRIC_KIND_COUNTER, Value: &metricv1.MetricRecord_IntValue{IntValue: 1}, Time: timestamppb.Now()}, + }, + } + if _, err := f.server.RecordMetrics(t.Context(), first); err != nil { + t.Fatalf("first RecordMetrics: %v", err) + } + + second := &kernelv1.RecordMetricsRequest{ + Metrics: []*metricv1.MetricRecord{ + {Name: "x", Kind: metricv1.MetricKind_METRIC_KIND_HISTOGRAM, Value: &metricv1.MetricRecord_DoubleValue{DoubleValue: 1}, Time: timestamppb.Now()}, + }, + } + _, err := f.server.RecordMetrics(t.Context(), second) + assertCode(t, err, codes.InvalidArgument) +} + +func TestServer_GetTelemetryConfig(t *testing.T) { + t.Parallel() + f := newTestServer(t, testProducer()) + + got, err := f.server.GetTelemetryConfig(t.Context(), &kernelv1.GetTelemetryConfigRequest{}) + if err != nil { + t.Fatalf("GetTelemetryConfig: %v", err) + } + if !got.TracesEnabled || !got.MetricsEnabled || !got.LogsEnabled { + t.Errorf("GetTelemetryConfig = %+v, want all three signals enabled (telemetry.DefaultConfig)", got) + } + if got.SamplingRatio != 1.0 { + t.Errorf("SamplingRatio = %v, want 1.0", got.SamplingRatio) + } +} + +func assertCode(t *testing.T, err error, want codes.Code) { + t.Helper() + if err == nil { + t.Fatalf("want a %v error, got nil", want) + } + st, ok := status.FromError(err) + if !ok { + t.Fatalf("error %v is not a gRPC status error", err) + } + if st.Code() != want { + t.Fatalf("status code = %v, want %v", st.Code(), want) + } +} diff --git a/internal/log/handler.go b/internal/log/handler.go index ee64fea..58b1e8e 100644 --- a/internal/log/handler.go +++ b/internal/log/handler.go @@ -27,61 +27,87 @@ func NewServer(logger *slog.Logger) *Server { return &Server{logger: logger} } -// Log implements the Log RPC (kernel-callbacks.md §5): it validates the -// incoming entry, converts it to a slog.Record, attaches session and +// Log implements the Log RPC (kernel-callbacks.md §5): it validates each +// entry in the batch, converts it to a slog.Record, attaches session and // producer attribution when present, and hands it to the configured -// logger's Handler. A malformed entry (missing a MUST field) is rejected -// with codes.InvalidArgument rather than logged with defaults filled in. -// LOG_LEVEL_FATAL is not special-cased beyond routing at that severity — -// per kernel-callbacks.md §5, it MUST NOT terminate the plugin or the -// kernel. +// logger's Handler. A malformed entry within an otherwise-valid batch +// (missing a MUST field) is skipped and warned about individually, not +// treated as failing the whole call — see kernel-callbacks.md §5. A +// request with zero entries, or one where every entry is malformed, fails +// the RPC with codes.InvalidArgument. LOG_LEVEL_FATAL is not special-cased +// beyond routing at that severity — per kernel-callbacks.md §5, it MUST +// NOT terminate the plugin or the kernel. func (s *Server) Log(ctx context.Context, req *kernelv1.LogRequest) (*kernelv1.LogResult, error) { - entry := req.GetEntry() - if entry == nil { + entries := req.GetEntries() + if len(entries) == 0 { // Log-and-return is the sanctioned gRPC-handler exception // (internal/CLAUDE.md) to go-style.md's "error or log it, never // both": the InvalidArgument status crosses the wire to the // remote plugin caller, which never sees this WARN, so there's // no in-process double-log. - s.warnInvalidEntry(ctx, "log: entry is required") - return nil, status.Error(codes.InvalidArgument, "log: entry is required") + s.warnInvalidEntry(ctx, "log: entries is required and must be non-empty") + return nil, status.Error(codes.InvalidArgument, "log: entries is required and must be non-empty") } - record, err := RecordFromEntry(entry) - if err != nil { - // Same log-and-return exception as above. - s.warnInvalidEntry(ctx, err.Error()) - return nil, status.Error(codes.InvalidArgument, err.Error()) - } + accepted := 0 + var lastErr error + for _, entry := range entries { + if entry == nil { + s.warnInvalidEntry(ctx, "log: entry is required") + lastErr = status.Error(codes.InvalidArgument, "log: entry is required") + continue + } - // req.SessionId is a proto3 `optional string` (*string): checked via - // the pointer, not the zero-value getter, so an omitted session_id and - // an explicitly-empty one stay distinguishable. - if req.SessionId != nil { - record.AddAttrs(slog.String("session_id", *req.SessionId)) - } + record, err := RecordFromEntry(entry) + if err != nil { + // Same log-and-return exception as above — one WARN per + // malformed entry, not one for the whole batch. + s.warnInvalidEntry(ctx, err.Error()) + lastErr = status.Error(codes.InvalidArgument, err.Error()) + continue + } + accepted++ - if p, ok := producer.FromContext(ctx); ok && p != nil { - record.AddAttrs( - slog.String("producer_category", p.GetCategory().String()), - slog.String("producer_name", p.GetName()), - slog.String("producer_version", p.GetVersion()), - ) - } + // req.SessionId is a proto3 `optional string` (*string): checked + // via the pointer, not the zero-value getter, so an omitted + // session_id and an explicitly-empty one stay distinguishable. + if req.SessionId != nil { + record.AddAttrs(slog.String("session_id", *req.SessionId)) + } - // Handler.Enabled must be checked before Handle, per slog's documented - // pattern for custom callers driving a Handler directly (the "Wrapping - // output methods" guidance in the log/slog package doc) — Handle - // itself may perform I/O we want to skip entirely when filtered out. - if !s.logger.Handler().Enabled(ctx, record.Level) { - return &kernelv1.LogResult{}, nil + if p, ok := producer.FromContext(ctx); ok && p != nil { + record.AddAttrs( + slog.String("producer_category", p.GetCategory().String()), + slog.String("producer_name", p.GetName()), + slog.String("producer_version", p.GetVersion()), + ) + } + + // Handler.Enabled must be checked before Handle, per slog's + // documented pattern for custom callers driving a Handler + // directly (the "Wrapping output methods" guidance in the + // log/slog package doc) — Handle itself may perform I/O we want + // to skip entirely when filtered out. + if !s.logger.Handler().Enabled(ctx, record.Level) { + continue + } + if err := s.logger.Handler().Handle(ctx, record); err != nil { + // Deliberately unlogged: this is the same Handler that just + // failed, so logging through it here would be self-defeating, + // and this package has no second logger to fall back to. The + // status.Errorf below is the only signal this failure + // produces. Unlike a malformed entry, a Handle failure aborts + // the remaining batch — it signals the sink itself is broken, + // not one bad caller-supplied entry. + return nil, status.Errorf(codes.Internal, "log: handle: %v", err) + } } - if err := s.logger.Handler().Handle(ctx, record); err != nil { - // Deliberately unlogged: this is the same Handler that just - // failed, so logging through it here would be self-defeating, - // and this package has no second logger to fall back to. The - // status.Errorf below is the only signal this failure produces. - return nil, status.Errorf(codes.Internal, "log: handle: %v", err) + + if accepted == 0 { + // Every entry in the batch was malformed — the same failure mode + // as the empty-batch case above, just discovered per-entry rather + // than up front. + return nil, lastErr } return &kernelv1.LogResult{}, nil diff --git a/internal/log/handler_test.go b/internal/log/handler_test.go index 6f5c120..b0c3534 100644 --- a/internal/log/handler_test.go +++ b/internal/log/handler_test.go @@ -7,6 +7,7 @@ import ( "github.com/pluggableharness/agent/internal/producer" commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + logv1 "github.com/pluggableharness/agent/pkg/log/proto/v1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -21,7 +22,7 @@ func TestServer_Log_valid(t *testing.T) { h := &fakeHandler{minLevel: LevelTrace} s := newTestServer(h) - req := &kernelv1.LogRequest{Entry: validEntry(t)} + req := &kernelv1.LogRequest{Entries: []*logv1.LogEntry{validEntry(t)}} result, err := s.Log(t.Context(), req) if err != nil { t.Fatalf("Log: unexpected error: %v", err) @@ -37,26 +38,91 @@ func TestServer_Log_valid(t *testing.T) { } } -func TestServer_Log_nilEntry(t *testing.T) { +func TestServer_Log_batch(t *testing.T) { t.Parallel() h := &fakeHandler{minLevel: LevelTrace} s := newTestServer(h) - _, err := s.Log(t.Context(), &kernelv1.LogRequest{Entry: nil}) + entry1 := validEntry(t) + entry1.Message = "first" + entry2 := validEntry(t) + entry2.Message = "second" + + req := &kernelv1.LogRequest{Entries: []*logv1.LogEntry{entry1, entry2}} + _, err := s.Log(t.Context(), req) + if err != nil { + t.Fatalf("Log: unexpected error: %v", err) + } + if len(h.records) != 2 { + t.Fatalf("handler captured %d records, want 2", len(h.records)) + } + if h.records[0].Message != "first" || h.records[1].Message != "second" { + t.Fatalf("captured messages = [%q, %q], want [first, second] in order", h.records[0].Message, h.records[1].Message) + } +} + +func TestServer_Log_emptyBatch(t *testing.T) { + t.Parallel() + h := &fakeHandler{minLevel: LevelTrace} + s := newTestServer(h) + + _, err := s.Log(t.Context(), &kernelv1.LogRequest{Entries: nil}) assertInvalidArgument(t, err) if len(h.records) != 1 { t.Fatalf("handler captured %d records, want 1 (the rejection WARN)", len(h.records)) } } -func TestServer_Log_malformedEntry(t *testing.T) { +func TestServer_Log_malformedEntrySkippedNotFailed(t *testing.T) { + t.Parallel() + h := &fakeHandler{minLevel: LevelTrace} + s := newTestServer(h) + + good := validEntry(t) + good.Message = "good entry" + bad := validEntry(t) + bad.Message = "" + + req := &kernelv1.LogRequest{Entries: []*logv1.LogEntry{bad, good}} + _, err := s.Log(t.Context(), req) + if err != nil { + t.Fatalf("Log: unexpected error for a batch with one malformed entry alongside a valid one: %v", err) + } + if len(h.records) != 2 { + t.Fatalf("handler captured %d records, want 2 (1 rejection WARN + 1 accepted entry)", len(h.records)) + } + if h.records[0].Level != slog.LevelWarn { + t.Fatalf("first record level = %v, want WARN (the malformed-entry rejection)", h.records[0].Level) + } + if h.records[1].Message != "good entry" { + t.Fatalf("second record message = %q, want %q", h.records[1].Message, "good entry") + } +} + +func TestServer_Log_allEntriesMalformedFailsBatch(t *testing.T) { + t.Parallel() + h := &fakeHandler{minLevel: LevelTrace} + s := newTestServer(h) + + bad1 := validEntry(t) + bad1.Message = "" + bad2 := validEntry(t) + bad2.Level = 0 // LOG_LEVEL_UNSPECIFIED, never valid on the wire + + req := &kernelv1.LogRequest{Entries: []*logv1.LogEntry{bad1, bad2}} + _, err := s.Log(t.Context(), req) + assertInvalidArgument(t, err) + if len(h.records) != 2 { + t.Fatalf("handler captured %d records, want 2 (one rejection WARN per malformed entry)", len(h.records)) + } +} + +func TestServer_Log_nilEntryInBatch(t *testing.T) { t.Parallel() h := &fakeHandler{minLevel: LevelTrace} s := newTestServer(h) - entry := validEntry(t) - entry.Message = "" - _, err := s.Log(t.Context(), &kernelv1.LogRequest{Entry: entry}) + _, err := s.Log(t.Context(), &kernelv1.LogRequest{Entries: []*logv1.LogEntry{nil}}) assertInvalidArgument(t, err) if len(h.records) != 1 { t.Fatalf("handler captured %d records, want 1 (the rejection WARN)", len(h.records)) @@ -71,7 +137,7 @@ func TestServer_Log_sessionID(t *testing.T) { h := &fakeHandler{minLevel: LevelTrace} s := newTestServer(h) sessionID := "sess-123" - _, err := s.Log(t.Context(), &kernelv1.LogRequest{Entry: validEntry(t), SessionId: &sessionID}) + _, err := s.Log(t.Context(), &kernelv1.LogRequest{Entries: []*logv1.LogEntry{validEntry(t)}, SessionId: &sessionID}) if err != nil { t.Fatalf("Log: unexpected error: %v", err) } @@ -85,7 +151,7 @@ func TestServer_Log_sessionID(t *testing.T) { t.Parallel() h := &fakeHandler{minLevel: LevelTrace} s := newTestServer(h) - _, err := s.Log(t.Context(), &kernelv1.LogRequest{Entry: validEntry(t)}) + _, err := s.Log(t.Context(), &kernelv1.LogRequest{Entries: []*logv1.LogEntry{validEntry(t)}}) if err != nil { t.Fatalf("Log: unexpected error: %v", err) } @@ -109,7 +175,7 @@ func TestServer_Log_producerAttribution(t *testing.T) { Version: "1.2.3", } ctx := producer.WithProducer(t.Context(), p) - _, err := s.Log(ctx, &kernelv1.LogRequest{Entry: validEntry(t)}) + _, err := s.Log(ctx, &kernelv1.LogRequest{Entries: []*logv1.LogEntry{validEntry(t)}}) if err != nil { t.Fatalf("Log: unexpected error: %v", err) } @@ -126,7 +192,7 @@ func TestServer_Log_producerAttribution(t *testing.T) { t.Parallel() h := &fakeHandler{minLevel: LevelTrace} s := newTestServer(h) - _, err := s.Log(t.Context(), &kernelv1.LogRequest{Entry: validEntry(t)}) + _, err := s.Log(t.Context(), &kernelv1.LogRequest{Entries: []*logv1.LogEntry{validEntry(t)}}) if err != nil { t.Fatalf("Log: unexpected error: %v", err) } @@ -145,10 +211,10 @@ func TestServer_Log_invalidEntryWarns(t *testing.T) { entry func(t *testing.T) *kernelv1.LogRequest }{ { - name: "nil entry", + name: "empty batch", entry: func(t *testing.T) *kernelv1.LogRequest { t.Helper() - return &kernelv1.LogRequest{Entry: nil} + return &kernelv1.LogRequest{Entries: nil} }, }, { @@ -157,7 +223,7 @@ func TestServer_Log_invalidEntryWarns(t *testing.T) { t.Helper() entry := validEntry(t) entry.Message = "" - return &kernelv1.LogRequest{Entry: entry} + return &kernelv1.LogRequest{Entries: []*logv1.LogEntry{entry}} }, }, } @@ -202,7 +268,7 @@ func TestServer_Log_invalidEntryWarnsWithProducer(t *testing.T) { } ctx := producer.WithProducer(t.Context(), p) - _, err := s.Log(ctx, &kernelv1.LogRequest{Entry: nil}) + _, err := s.Log(ctx, &kernelv1.LogRequest{Entries: []*logv1.LogEntry{nil}}) assertInvalidArgument(t, err) if len(h.records) != 1 { @@ -226,7 +292,7 @@ func TestServer_Log_belowThresholdSkipsHandle(t *testing.T) { s := newTestServer(h) entry := validEntry(t) // LOG_LEVEL_INFO, below the ERROR threshold - _, err := s.Log(t.Context(), &kernelv1.LogRequest{Entry: entry}) + _, err := s.Log(t.Context(), &kernelv1.LogRequest{Entries: []*logv1.LogEntry{entry}}) if err != nil { t.Fatalf("Log: unexpected error: %v", err) } diff --git a/internal/pluginruntime/CLAUDE.md b/internal/pluginruntime/CLAUDE.md index 383cc3f..ee53622 100644 --- a/internal/pluginruntime/CLAUDE.md +++ b/internal/pluginruntime/CLAUDE.md @@ -127,5 +127,12 @@ It exists purely to give `launch_integration_test.go` something real to dial — build-tagged `integration` so it's excluded from the default build, and living under `testdata/` so `go build ./...` skips it - regardless. Don't mistake it for the start of a real plugin-side SDK; - that's explicitly out of scope for this package (see README.md). + regardless. It is built entirely on `pkg/plugin`/`pkg/tool`/`pkg/hook` — + the real, third-party-consumable plugin-side SDK, which now exists — not + a hand-rolled `hashicorp/go-plugin` adapter; a passing + `TestLaunch_realSubprocess` is therefore this package's own end-to-end + proof that SDK actually round-trips through a real subprocess launch. + Still don't grow it into a second, parallel plugin SDK inside this + package, though: any new SDK ergonomics belong in `pkg/plugin` (or a + category's own `pkg/`) so every plugin author benefits, not + just this fixture. diff --git a/internal/pluginruntime/README.md b/internal/pluginruntime/README.md index 600aa75..a519a36 100644 --- a/internal/pluginruntime/README.md +++ b/internal/pluginruntime/README.md @@ -53,7 +53,8 @@ to a hard subprocess-tree teardown only if that window is exceeded. - `pkg/common` — the shared handshake config, protocol version constant, callback broker ID, and category→plugin-map-key helper this package (and - a future plugin-side SDK) both compile against. + `pkg/plugin` and the per-category `pkg/` SDKs, on the plugin + side) both compile against. - `internal/kernelcallback` — the composed `KernelCallbackServiceServer` this package serves on the callback broker. - `internal/telemetry` — the gRPC stats handlers wired onto both halves of diff --git a/internal/pluginruntime/adapter.go b/internal/pluginruntime/adapter.go index f640238..3d890a7 100644 --- a/internal/pluginruntime/adapter.go +++ b/internal/pluginruntime/adapter.go @@ -16,6 +16,7 @@ import ( kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" memoryv1 "github.com/pluggableharness/agent/pkg/memory/proto/v1" modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" + slashcommandv1 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" widgetv1 "github.com/pluggableharness/agent/pkg/widget/proto/v1" @@ -94,6 +95,8 @@ func newCategoryClient(category commonv1.Category, conn *grpc.ClientConn) (any, return frontendv1.NewFrontendServiceClient(conn), nil case commonv1.Category_CATEGORY_WIDGET: return widgetv1.NewWidgetServiceClient(conn), nil + case commonv1.Category_CATEGORY_SLASHCOMMAND: + return slashcommandv1.NewSlashCommandServiceClient(conn), nil default: return nil, fmt.Errorf("%w: %v", errUnrecognizedCategory, category) } diff --git a/internal/pluginruntime/adapter_test.go b/internal/pluginruntime/adapter_test.go index a906337..e786660 100644 --- a/internal/pluginruntime/adapter_test.go +++ b/internal/pluginruntime/adapter_test.go @@ -16,6 +16,7 @@ import ( frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" memoryv1 "github.com/pluggableharness/agent/pkg/memory/proto/v1" modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" + slashcommandv1 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" widgetv1 "github.com/pluggableharness/agent/pkg/widget/proto/v1" ) @@ -50,6 +51,7 @@ func TestPluginMap(t *testing.T) { commonv1.Category_CATEGORY_MEMORY, commonv1.Category_CATEGORY_FRONTEND, commonv1.Category_CATEGORY_WIDGET, + commonv1.Category_CATEGORY_SLASHCOMMAND, } { t.Run(category.String(), func(t *testing.T) { t.Parallel() @@ -93,6 +95,7 @@ func TestNewCategoryClient(t *testing.T) { {commonv1.Category_CATEGORY_MEMORY, memoryv1.MemoryServiceClient(nil)}, {commonv1.Category_CATEGORY_FRONTEND, frontendv1.FrontendServiceClient(nil)}, {commonv1.Category_CATEGORY_WIDGET, widgetv1.WidgetServiceClient(nil)}, + {commonv1.Category_CATEGORY_SLASHCOMMAND, slashcommandv1.SlashCommandServiceClient(nil)}, } for _, tt := range tests { t.Run(tt.category.String(), func(t *testing.T) { @@ -127,6 +130,10 @@ func TestNewCategoryClient(t *testing.T) { if _, ok := got.(widgetv1.WidgetServiceClient); !ok { t.Fatalf("got %T, want widgetv1.WidgetServiceClient", got) } + case commonv1.Category_CATEGORY_SLASHCOMMAND: + if _, ok := got.(slashcommandv1.SlashCommandServiceClient); !ok { + t.Fatalf("got %T, want slashcommandv1.SlashCommandServiceClient", got) + } } }) } diff --git a/internal/pluginruntime/doc.go b/internal/pluginruntime/doc.go index ae8b4fa..9483d26 100644 --- a/internal/pluginruntime/doc.go +++ b/internal/pluginruntime/doc.go @@ -1,6 +1,6 @@ -// Package pluginruntime is the kernel-side launcher for one of the six +// Package pluginruntime is the kernel-side launcher for one of the seven // out-of-process PluggableHarness Agent plugin categories (provider, tool, context, -// memory, frontend, widget), each speaking gRPC over +// memory, frontend, widget, slashcommand), each speaking gRPC over // github.com/hashicorp/go-plugin. // // Launch runs the full launch sequence — pre-flight version check, diff --git a/internal/pluginruntime/launch.go b/internal/pluginruntime/launch.go index 7653648..01c5bc1 100644 --- a/internal/pluginruntime/launch.go +++ b/internal/pluginruntime/launch.go @@ -1,6 +1,6 @@ // Package pluginruntime launches, dials, and shuts down one -// hashicorp/go-plugin subprocess for one of the six plugin categories -// (provider, tool, context, memory, frontend, widget), and serves the +// hashicorp/go-plugin subprocess for one of the seven plugin categories +// (provider, tool, context, memory, frontend, widget, slashcommand), and serves the // reverse KernelCallbackService channel back to it. See doc.go for the // package-level overview and README.md/CLAUDE.md for the fuller design // rationale. diff --git a/internal/pluginruntime/launch_integration_test.go b/internal/pluginruntime/launch_integration_test.go index be6b05f..de2a743 100644 --- a/internal/pluginruntime/launch_integration_test.go +++ b/internal/pluginruntime/launch_integration_test.go @@ -13,11 +13,13 @@ import ( "testing" "time" + "github.com/pluggableharness/agent/internal/eventbus" "github.com/pluggableharness/agent/internal/kernelcallback" "github.com/pluggableharness/agent/internal/log" "github.com/pluggableharness/agent/internal/pluginruntime" "github.com/pluggableharness/agent/internal/telemetry" "github.com/pluggableharness/agent/internal/telemetry/drivers/fake" + "github.com/pluggableharness/agent/internal/telemetryrelay" commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" ) @@ -106,9 +108,9 @@ func newFixtureLaunch(t *testing.T) (pluginruntime.Config, *captureHandler, *com Name: "fixture", Version: "0.0.1", } - cb := kernelcallback.NewServer(log.NewServer(logger), producer) - prov, err := telemetry.New(context.Background(), telemetry.DefaultConfig, fake.New(), nil) + telemetryBackend := fake.New() + prov, err := telemetry.New(context.Background(), telemetry.DefaultConfig, telemetryBackend, nil) if err != nil { t.Fatalf("telemetry.New: %v", err) } @@ -118,6 +120,18 @@ func newFixtureLaunch(t *testing.T) (pluginruntime.Config, *captureHandler, *com } }) + bus := eventbus.New() + t.Cleanup(func() { _ = bus.Close() }) + + cb := kernelcallback.NewServer(kernelcallback.Config{ + Log: log.NewServer(logger), + Producer: producer, + Telemetry: prov, + TelemetryRelay: telemetryrelay.New(telemetryBackend.RelayedSpans), + Bus: bus, + Logger: logger, + }) + return pluginruntime.Config{ BinaryPath: fixtureBinary, Producer: producer, diff --git a/internal/pluginruntime/testdata/plugin/main.go b/internal/pluginruntime/testdata/plugin/main.go index dc8b84c..25851a9 100644 --- a/internal/pluginruntime/testdata/plugin/main.go +++ b/internal/pluginruntime/testdata/plugin/main.go @@ -7,27 +7,34 @@ // KernelCallbackService.Log over the fixed callback broker ID // (pkg/common.CallbackBrokerID), proving the reverse channel. // -// This is the one place in internal/pluginruntime that implements the -// plugin *side* of the go-plugin adapter — purely for this fixture, never -// a template for a real plugin SDK (see ../../CLAUDE.md). Build-tagged -// integration so it never enters the default `go build ./...` (which -// already skips testdata/ regardless). +// Built on pkg/plugin and pkg/tool — the plugin-author SDK a real +// third-party plugin also imports — rather than hand-rolling the +// hashicorp/go-plugin adapter directly. This is the SDK's own +// end-to-end acceptance test: if this fixture, built with nothing but the +// public pkg/plugin/pkg/tool/pkg/hook surface, still round-trips through +// this package's real launch sequence, the SDK genuinely works. It also +// registers hook.Service alongside tool.Service on the same +// plugin.Config.Services slice, proving pkg/plugin's multi-service muxing +// (agent-loop/hook-dispatch.md's "one shared connection, more than one +// gRPC service") doesn't break a real subprocess launch — this fixture +// does not itself call DispatchHook, since internal/pluginruntime.Plugin +// deliberately dispenses only the primary category client (Dispensed()), +// not the raw *grpc.ClientConn a second service client would need; see +// this package's CLAUDE.md on why that boundary exists. +// +// Build-tagged integration so it never enters the default `go build ./...` +// (which already skips testdata/ regardless). package main import ( "context" - "errors" - "time" - - "github.com/hashicorp/go-plugin" - "google.golang.org/grpc" - "google.golang.org/protobuf/types/known/timestamppb" + "log/slog" - "github.com/pluggableharness/agent/pkg/common" commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" - kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" - logv1 "github.com/pluggableharness/agent/pkg/log/proto/v1" - toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" + "github.com/pluggableharness/agent/pkg/hook" + "github.com/pluggableharness/agent/pkg/plugin" + "github.com/pluggableharness/agent/pkg/schema" + "github.com/pluggableharness/agent/pkg/tool" ) // fixtureToolName is the single tool GetSchema reports — checked by @@ -36,75 +43,86 @@ import ( // other way. const fixtureToolName = "fixture_echo" -// toolServer is the canned ToolServiceServer this fixture serves. -type toolServer struct { - toolv1.UnimplementedToolServiceServer -} - -// GetSchema returns a single, fixed ToolSchema — the "one canned RPC" -// this fixture exists to round-trip. -func (toolServer) GetSchema(context.Context, *toolv1.GetSchemaRequest) (*toolv1.GetSchemaResponse, error) { - return &toolv1.GetSchemaResponse{ - Tools: []*toolv1.ToolSchema{ - { - Name: fixtureToolName, - Kind: toolv1.ToolKind_TOOL_KIND_RESOURCE, - Risk: toolv1.RiskClass_RISK_CLASS_LOW, - Description: "internal/pluginruntime integration fixture", - }, - }, - }, nil +// fixtureIdentity is this fixture's own self-reported plugin.Identity, per +// pkg/plugin.Identity's doc comment — used both for Describe (not +// exercised by this fixture's test) and for building tool.Service. +var fixtureIdentity = plugin.Identity{ + Name: "fixture", + Version: "0.0.0", + Source: "internal/pluginruntime/testdata/plugin", } -// toolPlugin is the plugin-side half of the go-plugin adapter for -// ToolService. -type toolPlugin struct { - plugin.Plugin +// fixtureProvider implements tool.Provider — the ToolService this fixture +// serves — and hook.Observer, muxed onto the same connection via +// plugin.Config.Services to prove multi-service registration works. +type fixtureProvider struct { + callback *plugin.Callback } -var _ plugin.GRPCPlugin = (*toolPlugin)(nil) - -// GRPCServer registers toolServer, then — in the background, since the -// kernel doesn't start serving the callback broker until it dispenses -// this plugin's client, which happens after GRPCServer must have already -// returned — dials the fixed callback broker ID and calls Log once. -func (toolPlugin) GRPCServer(broker *plugin.GRPCBroker, s *grpc.Server) error { - s.RegisterService(&toolv1.ToolService_ServiceDesc, toolServer{}) +var ( + _ tool.Provider = (*fixtureProvider)(nil) + _ hook.Observer = (*fixtureProvider)(nil) +) - go func() { - conn, err := broker.Dial(common.CallbackBrokerID) - if err != nil { - return - } - defer func() { _ = conn.Close() }() +// Schema returns a single, fixed tool.Schema — the "one canned RPC" this +// fixture exists to round-trip — and, on its way out, calls back into +// KernelCallbackService.Log via the SDK's own NewSlogHandler. This is the +// sanctioned call site for callback.Client per pkg/plugin's "callback- +// timing trap" doc comment: an RPC handler, invoked only once go-plugin +// has already begun dispensing this process's client to the kernel, never +// eagerly from a background goroutine at process start. +func (p *fixtureProvider) Schema(ctx context.Context) ([]*tool.Schema, error) { + if client, err := p.callback.Client(ctx); err == nil { + slog.New(client.NewSlogHandler()).Info("fixture plugin started") + } - client := kernelv1.NewKernelCallbackServiceClient(conn) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _, _ = client.Log(ctx, &kernelv1.LogRequest{ - Entry: &logv1.LogEntry{ - Level: logv1.LogLevel_LOG_LEVEL_INFO, - Logger: "pluginruntime.testdata.plugin", - Message: "fixture plugin started", - Time: timestamppb.Now(), - }, - }) - }() + empty, err := schema.Object(nil) + if err != nil { + return nil, err + } + return []*tool.Schema{ + { + Name: fixtureToolName, + Kind: tool.KindResource, + Risk: tool.RiskClassLow, + Description: "internal/pluginruntime integration fixture", + InputSchema: empty, + OutputSchema: empty, + Concurrency: &tool.ConcurrencySpec{Safe: true}, + Idempotent: true, + }, + }, nil +} +// Configure accepts any config; this fixture takes none. +func (p *fixtureProvider) Configure(context.Context, map[string]any) error { return nil } -// GRPCClient is never called plugin-side; this fixture only serves. -func (toolPlugin) GRPCClient(context.Context, *plugin.GRPCBroker, *grpc.ClientConn) (any, error) { - return nil, errors.New("testdata/plugin: GRPCClient is not used plugin-side") +// Invoke is never called by this fixture's test but must exist to satisfy +// tool.Provider. +func (p *fixtureProvider) Invoke(_ context.Context, call *tool.Call, stream *tool.Stream) error { + return stream.Send(tool.NewResultEvent(map[string]any{"echo": call.Arguments})) +} + +// Observe implements hook.Observer as a no-op — this fixture's test never +// dispatches a hook; the point is only that hook.NewService(p) can be +// registered alongside tool.NewService(p) without breaking the launch. +func (p *fixtureProvider) Observe(context.Context, *hook.Payload) error { + return nil } func main() { - plugin.Serve(&plugin.ServeConfig{ - HandshakeConfig: common.Handshake, - Plugins: plugin.PluginSet{ - common.PluginKey(commonv1.Category_CATEGORY_TOOL): &toolPlugin{}, + callback := plugin.NewCallback() + provider := &fixtureProvider{callback: callback} + + plugin.Serve(plugin.Config{ + Identity: fixtureIdentity, + Category: commonv1.Category_CATEGORY_TOOL, + Callback: callback, + Services: []plugin.Service{ + tool.NewService(provider, fixtureIdentity, callback), + hook.NewService(provider), }, - GRPCServer: plugin.DefaultGRPCServer, }) } diff --git a/internal/statebackend/doc.go b/internal/statebackend/doc.go index c50e1f8..71acfe4 100644 --- a/internal/statebackend/doc.go +++ b/internal/statebackend/doc.go @@ -56,8 +56,8 @@ // (.claude/rules/determinism.md). // - events.producer_category and producers.category store the lowercase // plugin-category vocabulary: model, tool, context, memory, -// frontend, widget — the same names docs/specifications/ uses as its -// own per-category directory names. +// frontend, widget, slashcommand — the same names docs/specifications/ +// uses as its own per-category directory names. // - Store.List and Store.Children read each file's session_meta row // directly (scan, statebackend.go), bypassing Open's schema-version // check and migration path — a metadata scan MUST NOT have the side diff --git a/internal/statebackend/event.go b/internal/statebackend/event.go index 9346ecc..6927a83 100644 --- a/internal/statebackend/event.go +++ b/internal/statebackend/event.go @@ -127,18 +127,19 @@ func decodeEventKind(text string) (kernelv1.EventKind, error) { // undocumented (unlike session_meta.status and events.kind, which the spec // enumerates literally) — this uses the same lowercase category names the // specifications/ tree itself uses as directory names (model/, tool/, -// context/, memory/, frontend/, widget/), for consistency with every other -// lowercase-text enum this package stores. CATEGORY_UNSPECIFIED is -// deliberately absent — a producer's category MUST NOT ever be -// unspecified (kernel-callbacks.md's server-derived producer identity is -// always a real category). +// context/, memory/, frontend/, widget/, slashcommand/), for consistency +// with every other lowercase-text enum this package stores. +// CATEGORY_UNSPECIFIED is deliberately absent — a producer's category MUST +// NOT ever be unspecified (kernel-callbacks.md's server-derived producer +// identity is always a real category). var producerCategoryText = map[commonv1.Category]string{ - commonv1.Category_CATEGORY_MODEL: "model", - commonv1.Category_CATEGORY_TOOL: "tool", - commonv1.Category_CATEGORY_CONTEXT: "context", - commonv1.Category_CATEGORY_MEMORY: "memory", - commonv1.Category_CATEGORY_FRONTEND: "frontend", - commonv1.Category_CATEGORY_WIDGET: "widget", + commonv1.Category_CATEGORY_MODEL: "model", + commonv1.Category_CATEGORY_TOOL: "tool", + commonv1.Category_CATEGORY_CONTEXT: "context", + commonv1.Category_CATEGORY_MEMORY: "memory", + commonv1.Category_CATEGORY_FRONTEND: "frontend", + commonv1.Category_CATEGORY_WIDGET: "widget", + commonv1.Category_CATEGORY_SLASHCOMMAND: "slashcommand", } // producerTextCategory is producerCategoryText inverted, built once from diff --git a/internal/telemetry/CLAUDE.md b/internal/telemetry/CLAUDE.md index 40d28f3..e17307e 100644 --- a/internal/telemetry/CLAUDE.md +++ b/internal/telemetry/CLAUDE.md @@ -82,14 +82,29 @@ case a caller already has one in hand; don't assume that's the only or primary path. -- **Export is direct-from-each-process, not funneled through a kernel - callback.** Both the kernel and every plugin export OTLP directly to the - same collector; nesting comes from W3C traceparent propagation across - the gRPC boundary (`grpchooks.go`'s `ClientHandler`/`ServerHandler`), not - from routing spans through `KernelCallbackService`. `kernel-callbacks.md` - §8 leaves open whether future primitives belong on that service — a - span-funnel RPC was considered and rejected (see `telemetry.go`'s and the - plan's reasoning); don't propose it without re-reading the tradeoff. +- **Span export now relays through `KernelCallbackService.ExportSpans` by + default — this reverses an earlier decision, not a stale note to + correct back.** A span-funnel RPC was originally considered and + rejected in favor of direct per-process OTLP export; `specifications/observability.md#the-relay-model` + records why that call was reversed (a plugin subprocess shouldn't need + network egress/collector credentials of its own, and the kernel becomes + the one place sampling/export config lives). `Backend.TraceUploader` + (`telemetry.go`) plus `internal/telemetryrelay` plus + `internal/kernelcallback`'s `ExportSpans` handler are that funnel, + already implemented. **Trace-context propagation across the plugin + boundary is unaffected by this** — nesting still comes entirely from + W3C traceparent propagation over the gRPC boundary + (`grpchooks.go`'s `ClientHandler`/`ServerHandler`); relay is a + transport decision about where a *finished* span's bytes go, not a + second mechanism for how an in-flight call's trace context crosses the + boundary. Metrics deliberately do **not** get the same transparent + relay — `RecordDynamicMetric` (`dynamicmetric.go`) records against + kernel-owned instruments instead, per the cardinality rule below and + `specifications/observability.md#the-tracing-metrics-asymmetry`. + `pkg/telemetry.Bootstrap` itself hasn't been switched to build on this + relay by default yet (tracked separately) — don't assume `Bootstrap`'s + current env-var-driven direct export is the intended end state; it's + what the plugin-facing SDK work will replace. - **Corrections needed elsewhere**, tracked per project `CLAUDE.md` convention: @@ -107,6 +122,15 @@ (renamed to drop package stutter). `internal/log` imports it like any other consumer; this package's future kernel-callback-server span attribution should do the same once that server exists. + 3. ~~Whether a span-funnel-through-the-kernel RPC belongs on + `KernelCallbackService` — considered and rejected in favor of direct + per-process OTLP export, see the bullet above (now superseded).~~ — + reversed: `ExportSpans` (`kernel-callbacks.md`), `Backend.TraceUploader` + (`telemetry.go`), and `internal/telemetryrelay` are that funnel, now + implemented. `specifications/observability.md#the-relay-model` + records the reversal's reasoning. `pkg/telemetry.Bootstrap` switching + its *default* to build on this relay (rather than direct export) is + separate, not-yet-done follow-up work. ## Logs integration (`sloghandler.go`) diff --git a/internal/telemetry/drivers/fake/fake.go b/internal/telemetry/drivers/fake/fake.go index b662147..c351381 100644 --- a/internal/telemetry/drivers/fake/fake.go +++ b/internal/telemetry/drivers/fake/fake.go @@ -13,10 +13,12 @@ import ( "context" "sync" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" sdklog "go.opentelemetry.io/otel/sdk/log" sdkmetric "go.opentelemetry.io/otel/sdk/metric" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" + tracepb "go.opentelemetry.io/proto/otlp/trace/v1" "github.com/pluggableharness/agent/internal/telemetry" ) @@ -26,19 +28,23 @@ import ( // telemetry.Provider first, then Spans.GetSpans()); Metrics is the reader // a test calls Collect on directly to pull current instrument state; Logs // is the recorder log records were exported into (ForceFlush first, then -// Logs.Records()). +// Logs.Records()); RelayedSpans records every ExportSpans-relayed +// ResourceSpans batch (TraceUploader), distinct from Spans, which only +// ever receives spans created by this process's own tracer. type Backend struct { - Spans *tracetest.InMemoryExporter - Metrics *sdkmetric.ManualReader - Logs *LogRecorder + Spans *tracetest.InMemoryExporter + Metrics *sdkmetric.ManualReader + Logs *LogRecorder + RelayedSpans *RelayedSpansRecorder } // New returns a Backend with fresh, empty recorders. func New() *Backend { return &Backend{ - Spans: tracetest.NewInMemoryExporter(), - Metrics: sdkmetric.NewManualReader(), - Logs: NewLogRecorder(), + Spans: tracetest.NewInMemoryExporter(), + Metrics: sdkmetric.NewManualReader(), + Logs: NewLogRecorder(), + RelayedSpans: NewRelayedSpansRecorder(), } } @@ -57,11 +63,62 @@ func (b *Backend) LogExporter(context.Context) (sdklog.Exporter, error) { return b.Logs, nil } +// TraceUploader returns b.RelayedSpans. +func (b *Backend) TraceUploader(context.Context) (otlptrace.Client, error) { + return b.RelayedSpans, nil +} + // Name returns "fake". func (*Backend) Name() string { return "fake" } var _ telemetry.Backend = (*Backend)(nil) +// RelayedSpansRecorder is a hand-written in-memory otlptrace.Client test +// double (go-testing.md: "fakes are hand-written"), used because the SDK +// ships no in-memory otlptrace.Client the way tracetest.InMemoryExporter +// covers a real sdktrace.SpanExporter. +type RelayedSpansRecorder struct { + mu sync.Mutex + spans []*tracepb.ResourceSpans +} + +// NewRelayedSpansRecorder returns an empty RelayedSpansRecorder. +func NewRelayedSpansRecorder() *RelayedSpansRecorder { + return &RelayedSpansRecorder{} +} + +// Start is a no-op; nothing needs connecting. +func (r *RelayedSpansRecorder) Start(context.Context) error { return nil } + +// Stop is a no-op; nothing needs releasing. +func (r *RelayedSpansRecorder) Stop(context.Context) error { return nil } + +// UploadTraces records spans for later assertion via ResourceSpans. +func (r *RelayedSpansRecorder) UploadTraces(_ context.Context, spans []*tracepb.ResourceSpans) error { + r.mu.Lock() + defer r.mu.Unlock() + r.spans = append(r.spans, spans...) + return nil +} + +// ResourceSpans returns a copy of every ResourceSpans recorded so far. +func (r *RelayedSpansRecorder) ResourceSpans() []*tracepb.ResourceSpans { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]*tracepb.ResourceSpans, len(r.spans)) + copy(out, r.spans) + return out +} + +// Reset clears all recorded ResourceSpans. +func (r *RelayedSpansRecorder) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + r.spans = nil +} + +var _ otlptrace.Client = (*RelayedSpansRecorder)(nil) + // LogRecorder is a hand-written in-memory sdklog.Exporter test double // (go-testing.md: "fakes are hand-written"), used because sdk/log v0.20.0 // ships no logs equivalent of tracetest.InMemoryExporter. diff --git a/internal/telemetry/drivers/fake/fake_test.go b/internal/telemetry/drivers/fake/fake_test.go index 114e9f6..5734deb 100644 --- a/internal/telemetry/drivers/fake/fake_test.go +++ b/internal/telemetry/drivers/fake/fake_test.go @@ -6,6 +6,7 @@ import ( otellog "go.opentelemetry.io/otel/log" sdklog "go.opentelemetry.io/otel/sdk/log" + tracepb "go.opentelemetry.io/proto/otlp/trace/v1" "github.com/pluggableharness/agent/internal/telemetry/drivers/fake" ) @@ -43,6 +44,14 @@ func TestBackend(t *testing.T) { if logExp != b.Logs { t.Error("LogExporter did not return b.Logs") } + + uploader, err := b.TraceUploader(ctx) + if err != nil { + t.Fatalf("TraceUploader: %v", err) + } + if uploader != b.RelayedSpans { + t.Error("TraceUploader did not return b.RelayedSpans") + } } func TestNew_freshRecorders(t *testing.T) { @@ -60,6 +69,42 @@ func TestNew_freshRecorders(t *testing.T) { if b1.Logs == b2.Logs { t.Error("New returned the same Logs recorder across two calls") } + if b1.RelayedSpans == b2.RelayedSpans { + t.Error("New returned the same RelayedSpans recorder across two calls") + } +} + +func TestRelayedSpansRecorder(t *testing.T) { + t.Parallel() + + r := fake.NewRelayedSpansRecorder() + ctx := context.Background() + + if got := r.ResourceSpans(); len(got) != 0 { + t.Fatalf("ResourceSpans() = %v, want empty", got) + } + + span := &tracepb.ResourceSpans{} + if err := r.UploadTraces(ctx, []*tracepb.ResourceSpans{span}); err != nil { + t.Fatalf("UploadTraces: %v", err) + } + + got := r.ResourceSpans() + if len(got) != 1 { + t.Fatalf("len(ResourceSpans()) = %d, want 1", len(got)) + } + + r.Reset() + if got := r.ResourceSpans(); len(got) != 0 { + t.Fatalf("ResourceSpans() after Reset = %v, want empty", got) + } + + if err := r.Start(ctx); err != nil { + t.Errorf("Start: %v", err) + } + if err := r.Stop(ctx); err != nil { + t.Errorf("Stop: %v", err) + } } func TestLogRecorder(t *testing.T) { diff --git a/internal/telemetry/drivers/noop/noop.go b/internal/telemetry/drivers/noop/noop.go index 4d4a51b..5c3da0c 100644 --- a/internal/telemetry/drivers/noop/noop.go +++ b/internal/telemetry/drivers/noop/noop.go @@ -19,10 +19,12 @@ package noop import ( "context" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" sdklog "go.opentelemetry.io/otel/sdk/log" sdkmetric "go.opentelemetry.io/otel/sdk/metric" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" + tracepb "go.opentelemetry.io/proto/otlp/trace/v1" "github.com/pluggableharness/agent/internal/telemetry" ) @@ -63,8 +65,27 @@ func (noopLogExporter) Export(context.Context, []sdklog.Record) error { return n func (noopLogExporter) Shutdown(context.Context) error { return nil } func (noopLogExporter) ForceFlush(context.Context) error { return nil } +// TraceUploader returns a discarding otlptrace.Client — the relay-path +// analog of TraceExporter/LogExporter's own discard-everything behavior. +// The SDK ships no noop otlptrace.Client (unlike tracetest.NewNoopExporter +// for a real sdktrace.SpanExporter), so this is hand-written, same as +// noopLogExporter above. +func (*Backend) TraceUploader(context.Context) (otlptrace.Client, error) { + return noopTraceUploader{}, nil +} + +// noopTraceUploader discards every relayed span it receives. +type noopTraceUploader struct{} + +func (noopTraceUploader) Start(context.Context) error { return nil } +func (noopTraceUploader) Stop(context.Context) error { return nil } +func (noopTraceUploader) UploadTraces(context.Context, []*tracepb.ResourceSpans) error { + return nil +} + // Name returns "noop". func (*Backend) Name() string { return "noop" } var _ telemetry.Backend = (*Backend)(nil) var _ sdklog.Exporter = noopLogExporter{} +var _ otlptrace.Client = noopTraceUploader{} diff --git a/internal/telemetry/drivers/noop/noop_test.go b/internal/telemetry/drivers/noop/noop_test.go index bc73215..40d8ecd 100644 --- a/internal/telemetry/drivers/noop/noop_test.go +++ b/internal/telemetry/drivers/noop/noop_test.go @@ -49,6 +49,20 @@ func TestBackend(t *testing.T) { if err := logExp.Shutdown(ctx); err != nil { t.Errorf("log exporter Shutdown: %v", err) } + + uploader, err := b.TraceUploader(ctx) + if err != nil { + t.Fatalf("TraceUploader: %v", err) + } + if err := uploader.Start(ctx); err != nil { + t.Errorf("uploader Start: %v", err) + } + if err := uploader.UploadTraces(ctx, nil); err != nil { + t.Errorf("UploadTraces: %v", err) + } + if err := uploader.Stop(ctx); err != nil { + t.Errorf("uploader Stop: %v", err) + } } // TestBackend_endToEnd wires the noop driver through telemetry.New and diff --git a/internal/telemetry/drivers/otlpgrpc/otlpgrpc.go b/internal/telemetry/drivers/otlpgrpc/otlpgrpc.go index 6fd3838..c3bc121 100644 --- a/internal/telemetry/drivers/otlpgrpc/otlpgrpc.go +++ b/internal/telemetry/drivers/otlpgrpc/otlpgrpc.go @@ -9,6 +9,7 @@ import ( "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" sdklog "go.opentelemetry.io/otel/sdk/log" sdkmetric "go.opentelemetry.io/otel/sdk/metric" @@ -75,6 +76,22 @@ func (b *Backend) LogExporter(ctx context.Context) (sdklog.Exporter, error) { return exp, nil } +// TraceUploader constructs an otlptracegrpc raw Client and starts it — +// see telemetry.Backend.TraceUploader's doc comment for why this bypasses +// otlptracegrpc.New's usual sdktrace.SpanExporter wrapping entirely. The +// caller owns calling Client.Stop when done relaying. +func (b *Backend) TraceUploader(ctx context.Context) (otlptrace.Client, error) { + opts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(b.cfg.Endpoint)} + if b.cfg.Insecure { + opts = append(opts, otlptracegrpc.WithInsecure()) + } + client := otlptracegrpc.NewClient(opts...) + if err := client.Start(ctx); err != nil { + return nil, fmt.Errorf("telemetry: otlpgrpc: trace uploader: %w", err) + } + return client, nil +} + // Name returns "otlpgrpc". func (*Backend) Name() string { return "otlpgrpc" } diff --git a/internal/telemetry/drivers/otlpgrpc/otlpgrpc_test.go b/internal/telemetry/drivers/otlpgrpc/otlpgrpc_test.go index 0ada890..c05ec0e 100644 --- a/internal/telemetry/drivers/otlpgrpc/otlpgrpc_test.go +++ b/internal/telemetry/drivers/otlpgrpc/otlpgrpc_test.go @@ -50,4 +50,12 @@ func TestBackend(t *testing.T) { if err := logExp.Shutdown(ctx); err != nil { t.Errorf("log exporter Shutdown: %v", err) } + + uploader, err := b.TraceUploader(ctx) + if err != nil { + t.Fatalf("TraceUploader: %v", err) + } + if err := uploader.Stop(ctx); err != nil { + t.Errorf("uploader Stop: %v", err) + } } diff --git a/internal/telemetry/drivers/otlphttp/otlphttp.go b/internal/telemetry/drivers/otlphttp/otlphttp.go index cccda05..412be60 100644 --- a/internal/telemetry/drivers/otlphttp/otlphttp.go +++ b/internal/telemetry/drivers/otlphttp/otlphttp.go @@ -9,6 +9,7 @@ import ( "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" sdklog "go.opentelemetry.io/otel/sdk/log" sdkmetric "go.opentelemetry.io/otel/sdk/metric" @@ -71,6 +72,22 @@ func (b *Backend) LogExporter(ctx context.Context) (sdklog.Exporter, error) { return exp, nil } +// TraceUploader constructs an otlptracehttp raw Client and starts it — +// see telemetry.Backend.TraceUploader's doc comment for why this bypasses +// otlptracehttp.New's usual sdktrace.SpanExporter wrapping entirely. The +// caller owns calling Client.Stop when done relaying. +func (b *Backend) TraceUploader(ctx context.Context) (otlptrace.Client, error) { + opts := []otlptracehttp.Option{otlptracehttp.WithEndpoint(b.cfg.Endpoint)} + if b.cfg.Insecure { + opts = append(opts, otlptracehttp.WithInsecure()) + } + client := otlptracehttp.NewClient(opts...) + if err := client.Start(ctx); err != nil { + return nil, fmt.Errorf("telemetry: otlphttp: trace uploader: %w", err) + } + return client, nil +} + // Name returns "otlphttp". func (*Backend) Name() string { return "otlphttp" } diff --git a/internal/telemetry/drivers/otlphttp/otlphttp_test.go b/internal/telemetry/drivers/otlphttp/otlphttp_test.go index 73f0372..f3530a0 100644 --- a/internal/telemetry/drivers/otlphttp/otlphttp_test.go +++ b/internal/telemetry/drivers/otlphttp/otlphttp_test.go @@ -49,4 +49,12 @@ func TestBackend(t *testing.T) { if err := logExp.Shutdown(ctx); err != nil { t.Errorf("log exporter Shutdown: %v", err) } + + uploader, err := b.TraceUploader(ctx) + if err != nil { + t.Fatalf("TraceUploader: %v", err) + } + if err := uploader.Stop(ctx); err != nil { + t.Errorf("uploader Stop: %v", err) + } } diff --git a/internal/telemetry/drivers/stdout/stdout.go b/internal/telemetry/drivers/stdout/stdout.go index f43d0e6..6a6654d 100644 --- a/internal/telemetry/drivers/stdout/stdout.go +++ b/internal/telemetry/drivers/stdout/stdout.go @@ -6,13 +6,17 @@ package stdout import ( "context" "fmt" + "os" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" "go.opentelemetry.io/otel/exporters/stdout/stdoutlog" "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" sdklog "go.opentelemetry.io/otel/sdk/log" sdkmetric "go.opentelemetry.io/otel/sdk/metric" sdktrace "go.opentelemetry.io/otel/sdk/trace" + tracepb "go.opentelemetry.io/proto/otlp/trace/v1" + "google.golang.org/protobuf/encoding/protojson" "github.com/pluggableharness/agent/internal/telemetry" ) @@ -57,7 +61,40 @@ func (*Backend) LogExporter(context.Context) (sdklog.Exporter, error) { return exp, nil } +// TraceUploader returns a Client that pretty-prints each relayed +// ResourceSpans batch to stdout — the relay-path analog of TraceExporter, +// since a relayed span (specifications/observability.md#the-relay-model) +// never passes through this process's own sdktrace.TracerProvider/ +// stdouttrace exporter pipeline. There is no stdouttrace equivalent that +// accepts already-built ResourceSpans protos directly, so this is +// hand-written using protojson, matching this driver's existing +// pretty-print-for-a-human intent. +func (*Backend) TraceUploader(context.Context) (otlptrace.Client, error) { + return stdoutTraceUploader{}, nil +} + +// stdoutTraceUploader writes each relayed ResourceSpans, pretty-printed, +// to stdout. +type stdoutTraceUploader struct{} + +func (stdoutTraceUploader) Start(context.Context) error { return nil } +func (stdoutTraceUploader) Stop(context.Context) error { return nil } +func (stdoutTraceUploader) UploadTraces(_ context.Context, spans []*tracepb.ResourceSpans) error { + marshaler := protojson.MarshalOptions{Multiline: true} + for _, rs := range spans { + b, err := marshaler.Marshal(rs) + if err != nil { + return fmt.Errorf("telemetry: stdout: trace uploader: marshal: %w", err) + } + if _, err := os.Stdout.Write(append(b, '\n')); err != nil { + return fmt.Errorf("telemetry: stdout: trace uploader: write: %w", err) + } + } + return nil +} + // Name returns "stdout". func (*Backend) Name() string { return "stdout" } var _ telemetry.Backend = (*Backend)(nil) +var _ otlptrace.Client = stdoutTraceUploader{} diff --git a/internal/telemetry/drivers/stdout/stdout_test.go b/internal/telemetry/drivers/stdout/stdout_test.go index 284a1c9..26f513f 100644 --- a/internal/telemetry/drivers/stdout/stdout_test.go +++ b/internal/telemetry/drivers/stdout/stdout_test.go @@ -2,8 +2,12 @@ package stdout_test import ( "context" + "io" + "os" "testing" + tracepb "go.opentelemetry.io/proto/otlp/trace/v1" + "github.com/pluggableharness/agent/internal/telemetry/drivers/stdout" ) @@ -46,4 +50,50 @@ func TestBackend(t *testing.T) { if err := logExp.Shutdown(ctx); err != nil { t.Errorf("log exporter Shutdown: %v", err) } + + uploader, err := b.TraceUploader(ctx) + if err != nil { + t.Fatalf("TraceUploader: %v", err) + } + if err := uploader.Start(ctx); err != nil { + t.Errorf("uploader Start: %v", err) + } + if err := uploader.Stop(ctx); err != nil { + t.Errorf("uploader Stop: %v", err) + } +} + +// TestBackend_traceUploaderWritesToStdout confirms UploadTraces actually +// prints something rather than silently discarding — the one behavior +// that distinguishes this driver's TraceUploader from noop's. +func TestBackend_traceUploaderWritesToStdout(t *testing.T) { + b := stdout.New() + uploader, err := b.TraceUploader(context.Background()) + if err != nil { + t.Fatalf("TraceUploader: %v", err) + } + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + orig := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = orig }) + + spans := []*tracepb.ResourceSpans{{}} + if err := uploader.UploadTraces(context.Background(), spans); err != nil { + t.Fatalf("UploadTraces: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("close write end: %v", err) + } + + out, err := io.ReadAll(r) + if err != nil { + t.Fatalf("read pipe: %v", err) + } + if len(out) == 0 { + t.Fatal("UploadTraces wrote nothing to stdout") + } } diff --git a/internal/telemetry/dynamicmetric.go b/internal/telemetry/dynamicmetric.go new file mode 100644 index 0000000..6ed16c6 --- /dev/null +++ b/internal/telemetry/dynamicmetric.go @@ -0,0 +1,185 @@ +package telemetry + +import ( + "context" + "fmt" + "sort" + "sync" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +// MaxDynamicMetricAttributes bounds the number of attribute keys +// RecordDynamicMetric keeps per observation before recording it — a +// plugin-supplied metric.v1.MetricRecord.attributes map is open-ended by +// construction, and .claude/rules/logging-telemetry.md's cardinality rule +// is non-negotiable (specifications/observability.md#the-tracing-metrics-asymmetry). +// A key beyond this bound is dropped, not the whole observation, and the +// drop is counted via Instruments.RecordMetricsAttributesDropped rather +// than silently disappearing with no signal. +const MaxDynamicMetricAttributes = 8 + +// DynamicMetricKind selects which OTel instrument shape +// RecordDynamicMetric creates for a given name — the shape a plugin +// declares via metric.v1.MetricKind (kernelcallback translates the wire +// enum into this local type; this package intentionally does not import +// the kernel proto package to describe its own OTel-facing surface). +type DynamicMetricKind int + +const ( + // DynamicMetricKindUnspecified is the zero value — never a valid + // argument to RecordDynamicMetric. + DynamicMetricKindUnspecified DynamicMetricKind = iota + // DynamicMetricKindCounter is a monotonically increasing sum. + DynamicMetricKindCounter + // DynamicMetricKindUpDownCounter is a sum that can increase or + // decrease. + DynamicMetricKindUpDownCounter + // DynamicMetricKindHistogram is one observation to be aggregated into + // a distribution. + DynamicMetricKindHistogram +) + +// ErrDynamicMetricKindMismatch is returned by RecordDynamicMetric when a +// name is reused with a kind that disagrees with the instrument already +// created for it — kernel-callbacks.md's RecordMetrics documents this as +// a MUST reject, since OTel does not allow one instrument name to change +// shape mid-process. +var ErrDynamicMetricKindMismatch = fmt.Errorf("telemetry: dynamic metric: kind mismatch") + +// ErrDynamicMetricKindUnspecified is returned by RecordDynamicMetric when +// kind is DynamicMetricKindUnspecified. +var ErrDynamicMetricKindUnspecified = fmt.Errorf("telemetry: dynamic metric: kind unspecified") + +// dynamicInstrument is whichever one of the three OTel instrument types a +// given name was first created as — exactly one field is ever set, +// matching the kind it was created under. +type dynamicInstrument struct { + kind DynamicMetricKind + counter metric.Float64Counter + updown metric.Float64UpDownCounter + histogram metric.Float64Histogram +} + +// dynamicMetrics lazily creates and caches metric instruments by name, for +// RecordMetrics' plugin-declared observations — a case Instruments' fixed +// struct doesn't cover, since instrument names here are runtime/plugin- +// declared rather than a fixed compile-time set. Every dynamic instrument +// is Float64-shaped regardless of whether the originating observation was +// int64 or double-valued: OTel's own API splits Int64*/Float64* instrument +// families, and tracking both per name would double the bookkeeping for +// no practical benefit at this value range — a plugin's int64 count is +// losslessly representable as a float64 well past any realistic counter +// value. +type dynamicMetrics struct { + meter metric.Meter + + mu sync.Mutex + instruments map[string]*dynamicInstrument +} + +func newDynamicMetrics(meter metric.Meter) *dynamicMetrics { + return &dynamicMetrics{ + meter: meter, + instruments: make(map[string]*dynamicInstrument), + } +} + +// getOrCreate returns the cached instrument for name, creating it against +// kind on first use. A second call for the same name with a different +// kind returns ErrDynamicMetricKindMismatch rather than silently reusing +// (or silently replacing) the original instrument. +func (d *dynamicMetrics) getOrCreate(name string, kind DynamicMetricKind) (*dynamicInstrument, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if inst, ok := d.instruments[name]; ok { + if inst.kind != kind { + return nil, fmt.Errorf("%w: %q was created as kind %d, called again as kind %d", ErrDynamicMetricKindMismatch, name, inst.kind, kind) + } + return inst, nil + } + + inst := &dynamicInstrument{kind: kind} + var err error + switch kind { + case DynamicMetricKindCounter: + inst.counter, err = d.meter.Float64Counter(name) + case DynamicMetricKindUpDownCounter: + inst.updown, err = d.meter.Float64UpDownCounter(name) + case DynamicMetricKindHistogram: + inst.histogram, err = d.meter.Float64Histogram(name) + case DynamicMetricKindUnspecified: + return nil, ErrDynamicMetricKindUnspecified + default: + return nil, fmt.Errorf("telemetry: dynamic metric: unknown kind %d", kind) + } + if err != nil { + return nil, fmt.Errorf("telemetry: dynamic metric: create %q: %w", name, err) + } + + d.instruments[name] = inst + return inst, nil +} + +// boundAttributes converts attrs into attribute.KeyValue pairs, sorted by +// key for deterministic ordering (.claude/rules/determinism.md's +// no-map-iteration-order rule), truncated to MaxDynamicMetricAttributes +// entries. It reports how many keys were dropped, so the caller can +// increment a dropped-attributes counter and log once per call, not once +// per dropped key. +func boundAttributes(attrs map[string]string) (kvs []attribute.KeyValue, dropped int) { + if len(attrs) == 0 { + return nil, 0 + } + keys := make([]string, 0, len(attrs)) + for k := range attrs { + keys = append(keys, k) + } + sort.Strings(keys) + + if len(keys) > MaxDynamicMetricAttributes { + dropped = len(keys) - MaxDynamicMetricAttributes + keys = keys[:MaxDynamicMetricAttributes] + } + + kvs = make([]attribute.KeyValue, 0, len(keys)) + for _, k := range keys { + kvs = append(kvs, attribute.String(k, attrs[k])) + } + return kvs, dropped +} + +// RecordDynamicMetric records one plugin-declared metric observation +// against a lazily-created, per-name instrument on p's own MeterProvider +// — see specifications/observability.md#the-tracing-metrics-asymmetry for +// why this does not relay OTLP the way ExportSpans does. name is the +// fully-qualified instrument name ("plugin.{category}.{name}.{metric +// name}", server-derived by the caller from the authenticated callback +// connection — this method does not construct it and does not validate +// its shape). attrs is bounded to MaxDynamicMetricAttributes keys; excess +// keys are dropped and counted via Instruments().RecordMetricsAttributesDropped, +// never silently accepted. +func (p *Provider) RecordDynamicMetric(ctx context.Context, name string, kind DynamicMetricKind, value float64, attrs map[string]string) error { + inst, err := p.dynamicMetrics.getOrCreate(name, kind) + if err != nil { + return err + } + + kvs, dropped := boundAttributes(attrs) + if dropped > 0 { + p.instruments.RecordMetricsAttributesDropped.Add(ctx, int64(dropped)) + } + opt := metric.WithAttributes(kvs...) + + switch kind { + case DynamicMetricKindCounter: + inst.counter.Add(ctx, value, opt) + case DynamicMetricKindUpDownCounter: + inst.updown.Add(ctx, value, opt) + case DynamicMetricKindHistogram: + inst.histogram.Record(ctx, value, opt) + } + return nil +} diff --git a/internal/telemetry/dynamicmetric_test.go b/internal/telemetry/dynamicmetric_test.go new file mode 100644 index 0000000..9e35d9f --- /dev/null +++ b/internal/telemetry/dynamicmetric_test.go @@ -0,0 +1,106 @@ +package telemetry + +import ( + "errors" + "testing" + + "go.opentelemetry.io/otel/metric/noop" +) + +func TestBoundAttributes(t *testing.T) { + t.Parallel() + + t.Run("empty", func(t *testing.T) { + t.Parallel() + kvs, dropped := boundAttributes(nil) + if kvs != nil || dropped != 0 { + t.Fatalf("boundAttributes(nil) = %v, %d; want nil, 0", kvs, dropped) + } + }) + + t.Run("under the bound", func(t *testing.T) { + t.Parallel() + kvs, dropped := boundAttributes(map[string]string{"b": "2", "a": "1"}) + if dropped != 0 { + t.Fatalf("dropped = %d, want 0", dropped) + } + if len(kvs) != 2 { + t.Fatalf("len(kvs) = %d, want 2", len(kvs)) + } + // sorted by key, deterministically, regardless of map iteration order. + if kvs[0].Key != "a" || kvs[1].Key != "b" { + t.Fatalf("kvs = %+v, want sorted [a, b]", kvs) + } + }) + + t.Run("over the bound", func(t *testing.T) { + t.Parallel() + attrs := make(map[string]string, MaxDynamicMetricAttributes+3) + for i := range MaxDynamicMetricAttributes + 3 { + attrs[string(rune('a'+i))] = "v" + } + kvs, dropped := boundAttributes(attrs) + if dropped != 3 { + t.Fatalf("dropped = %d, want 3", dropped) + } + if len(kvs) != MaxDynamicMetricAttributes { + t.Fatalf("len(kvs) = %d, want %d", len(kvs), MaxDynamicMetricAttributes) + } + // The kept keys are the lexicographically first MaxDynamicMetricAttributes. + if kvs[0].Key != "a" { + t.Fatalf("kvs[0].Key = %q, want a", kvs[0].Key) + } + }) +} + +func TestDynamicMetrics_getOrCreate(t *testing.T) { + t.Parallel() + + dm := newDynamicMetrics(noop.NewMeterProvider().Meter("test")) + + inst1, err := dm.getOrCreate("plugin.tool.github.calls", DynamicMetricKindCounter) + if err != nil { + t.Fatalf("getOrCreate: %v", err) + } + if inst1.counter == nil { + t.Fatal("counter instrument not created") + } + + inst2, err := dm.getOrCreate("plugin.tool.github.calls", DynamicMetricKindCounter) + if err != nil { + t.Fatalf("getOrCreate (second call, same kind): %v", err) + } + if inst1 != inst2 { + t.Fatal("getOrCreate created a second instrument for the same name/kind instead of returning the cached one") + } + + if _, err := dm.getOrCreate("plugin.tool.github.calls", DynamicMetricKindHistogram); !errors.Is(err, ErrDynamicMetricKindMismatch) { + t.Fatalf("getOrCreate (kind mismatch) = %v, want ErrDynamicMetricKindMismatch", err) + } + + if _, err := dm.getOrCreate("plugin.tool.github.other", DynamicMetricKindUnspecified); !errors.Is(err, ErrDynamicMetricKindUnspecified) { + t.Fatalf("getOrCreate (unspecified kind) = %v, want ErrDynamicMetricKindUnspecified", err) + } +} + +func TestDynamicMetrics_upDownCounterAndHistogram(t *testing.T) { + t.Parallel() + + dm := newDynamicMetrics(noop.NewMeterProvider().Meter("test")) + + updown, err := dm.getOrCreate("plugin.tool.github.active", DynamicMetricKindUpDownCounter) + if err != nil { + t.Fatalf("getOrCreate (up_down_counter): %v", err) + } + if updown.updown == nil { + t.Fatal("up_down_counter instrument not created") + } + + hist, err := dm.getOrCreate("plugin.tool.github.duration", DynamicMetricKindHistogram) + if err != nil { + t.Fatalf("getOrCreate (histogram): %v", err) + } + if hist.histogram == nil { + t.Fatal("histogram instrument not created") + } +} diff --git a/internal/telemetry/instrument.go b/internal/telemetry/instrument.go index bbd8808..149c7cb 100644 --- a/internal/telemetry/instrument.go +++ b/internal/telemetry/instrument.go @@ -40,6 +40,25 @@ type Instruments struct { EventBusEventsPublished metric.Int64Counter EventBusEventsDelivered metric.Int64Counter EventBusSubscriptionsActive metric.Int64UpDownCounter + + // EventBusSubscribeStreamsClosed counts a kernelcallback Subscribe + // stream the kernel closed unilaterally for exceeding its per-stream + // backpressure bound (event-bus.md#backpressure) — a signal that a + // slow-consumer disconnect happened, distinct from an ordinary + // caller-initiated stream close. + EventBusSubscribeStreamsClosed metric.Int64Counter + + // RelayedSpans counts spans successfully relayed via ExportSpans + // (observability.md#the-relay-model) — incremented once per span in + // a batch, not once per ExportSpans call. + RelayedSpans metric.Int64Counter + + // RecordMetricsAttributesDropped counts attribute keys dropped by + // RecordDynamicMetric's cardinality bound + // (observability.md#the-tracing-metrics-asymmetry) — incremented by + // however many keys a single observation dropped, not once per + // observation. + RecordMetricsAttributesDropped metric.Int64Counter } // newInstruments registers every instrument against meter. An error here @@ -127,6 +146,18 @@ func newInstruments(meter metric.Meter) (*Instruments, error) { metric.WithDescription("Currently open internal/eventbus subscriptions, across all topics.")) check("pluggableharness.eventbus.subscriptions.active", err) + eventBusSubscribeStreamsClosed, err := meter.Int64Counter("pluggableharness.eventbus.subscribe_streams.closed", + metric.WithDescription("Subscribe streams the kernel closed unilaterally for exceeding their backpressure bound (slow-consumer disconnect).")) + check("pluggableharness.eventbus.subscribe_streams.closed", err) + + relayedSpans, err := meter.Int64Counter("pluggableharness.telemetry.relayed_spans", + metric.WithDescription("Spans successfully relayed via ExportSpans, one per span.")) + check("pluggableharness.telemetry.relayed_spans", err) + + recordMetricsAttributesDropped, err := meter.Int64Counter("pluggableharness.telemetry.record_metrics.attributes_dropped", + metric.WithDescription("Attribute keys dropped by RecordMetrics' per-instrument cardinality bound.")) + check("pluggableharness.telemetry.record_metrics.attributes_dropped", err) + if len(errs) > 0 { return nil, errors.Join(errs...) } @@ -150,5 +181,9 @@ func newInstruments(meter metric.Meter) (*Instruments, error) { EventBusEventsPublished: eventBusEventsPublished, EventBusEventsDelivered: eventBusEventsDelivered, EventBusSubscriptionsActive: eventBusSubscriptionsActive, + + EventBusSubscribeStreamsClosed: eventBusSubscribeStreamsClosed, + RelayedSpans: relayedSpans, + RecordMetricsAttributesDropped: recordMetricsAttributesDropped, }, nil } diff --git a/internal/telemetry/instrument_test.go b/internal/telemetry/instrument_test.go index 8aee409..aee98d5 100644 --- a/internal/telemetry/instrument_test.go +++ b/internal/telemetry/instrument_test.go @@ -88,4 +88,10 @@ func TestInstruments_smoke(t *testing.T) { instruments.ToolDuration.Record(ctx, 1.0) instruments.HookDuration.Record(ctx, 1.0) instruments.ActiveSessions.Add(ctx, 1) + instruments.EventBusEventsPublished.Add(ctx, 1) + instruments.EventBusEventsDelivered.Add(ctx, 1) + instruments.EventBusSubscriptionsActive.Add(ctx, 1) + instruments.EventBusSubscribeStreamsClosed.Add(ctx, 1) + instruments.RelayedSpans.Add(ctx, 1) + instruments.RecordMetricsAttributesDropped.Add(ctx, 1) } diff --git a/internal/telemetry/recorddynamicmetric_test.go b/internal/telemetry/recorddynamicmetric_test.go new file mode 100644 index 0000000..5f4c2ef --- /dev/null +++ b/internal/telemetry/recorddynamicmetric_test.go @@ -0,0 +1,67 @@ +package telemetry_test + +import ( + "context" + "errors" + "testing" + + "go.opentelemetry.io/otel/sdk/metric/metricdata" + + "github.com/pluggableharness/agent/internal/telemetry" +) + +func TestRecordDynamicMetric_counter(t *testing.T) { + t.Parallel() + p, backend := newTestProvider(t) + + err := p.RecordDynamicMetric(context.Background(), "plugin.tool.github.calls", telemetry.DynamicMetricKindCounter, 3, map[string]string{"status": "ok"}) + if err != nil { + t.Fatalf("RecordDynamicMetric: %v", err) + } + + var rm metricdata.ResourceMetrics + if err := backend.Metrics.Collect(context.Background(), &rm); err != nil { + t.Fatalf("Collect: %v", err) + } + sum := findFloatSum(t, rm, "plugin.tool.github.calls") + if len(sum.DataPoints) != 1 || sum.DataPoints[0].Value != 3 { + t.Fatalf("data points = %+v, want one point of 3", sum.DataPoints) + } +} + +func TestRecordDynamicMetric_kindMismatch(t *testing.T) { + t.Parallel() + p, _ := newTestProvider(t) + ctx := context.Background() + + if err := p.RecordDynamicMetric(ctx, "plugin.tool.github.x", telemetry.DynamicMetricKindCounter, 1, nil); err != nil { + t.Fatalf("first RecordDynamicMetric: %v", err) + } + err := p.RecordDynamicMetric(ctx, "plugin.tool.github.x", telemetry.DynamicMetricKindHistogram, 1, nil) + if !errors.Is(err, telemetry.ErrDynamicMetricKindMismatch) { + t.Fatalf("second RecordDynamicMetric (different kind) = %v, want ErrDynamicMetricKindMismatch", err) + } +} + +func TestRecordDynamicMetric_attributesDroppedOverBound(t *testing.T) { + t.Parallel() + p, backend := newTestProvider(t) + ctx := context.Background() + + attrs := make(map[string]string, telemetry.MaxDynamicMetricAttributes+2) + for i := range telemetry.MaxDynamicMetricAttributes + 2 { + attrs[string(rune('a'+i))] = "v" + } + if err := p.RecordDynamicMetric(ctx, "plugin.tool.github.attrs", telemetry.DynamicMetricKindCounter, 1, attrs); err != nil { + t.Fatalf("RecordDynamicMetric: %v", err) + } + + var rm metricdata.ResourceMetrics + if err := backend.Metrics.Collect(context.Background(), &rm); err != nil { + t.Fatalf("Collect: %v", err) + } + dropped := findSum(t, rm, "pluggableharness.telemetry.record_metrics.attributes_dropped") + if len(dropped.DataPoints) != 1 || dropped.DataPoints[0].Value != 2 { + t.Fatalf("dropped-attributes data points = %+v, want one point of 2", dropped.DataPoints) + } +} diff --git a/internal/telemetry/span.go b/internal/telemetry/span.go index 080e5f5..8ce6b9d 100644 --- a/internal/telemetry/span.go +++ b/internal/telemetry/span.go @@ -43,6 +43,15 @@ const ( spanNameStateBackendPlanItemsQuery = "statebackend.query.plan_items" spanNameEventBusPublish = "eventbus.publish" + + spanNameKernelCallbackExportSpans = "kernelcallback.export_spans" + spanNameKernelCallbackRecordMetrics = "kernelcallback.record_metrics" + spanNameKernelCallbackGetTelemetryConfig = "kernelcallback.get_telemetry_config" + spanNameKernelCallbackGetConfig = "kernelcallback.get_config" + spanNameKernelCallbackPublish = "kernelcallback.publish" + spanNameKernelCallbackSubscribe = "kernelcallback.subscribe" + spanNameKernelCallbackReadEvents = "kernelcallback.read_events" + spanNameKernelCallbackGetSession = "kernelcallback.get_session" ) // SessionSpan describes the session a StartSession call is opening @@ -286,6 +295,64 @@ func (p *Provider) StartEventBusPublish(ctx context.Context, topic string) (cont return p.tracer.Start(ctx, spanNameEventBusPublish, trace.WithAttributes(EventBusTopicKey.String(topic))) } +// StartKernelCallbackExportSpans opens the span covering one ExportSpans +// call (kernel-callbacks.md's ExportSpans) — the relay-bridge handler's +// own span, distinct from any span carried inside the relayed batch +// itself (observability.md#the-relay-model). +func (p *Provider) StartKernelCallbackExportSpans(ctx context.Context, producer *commonv1.ProducerRef) (context.Context, trace.Span) { + return p.tracer.Start(ctx, spanNameKernelCallbackExportSpans, trace.WithAttributes(producerAttributes(producer)...)) +} + +// StartKernelCallbackRecordMetrics opens the span covering one +// RecordMetrics call (kernel-callbacks.md's RecordMetrics). +func (p *Provider) StartKernelCallbackRecordMetrics(ctx context.Context, producer *commonv1.ProducerRef) (context.Context, trace.Span) { + return p.tracer.Start(ctx, spanNameKernelCallbackRecordMetrics, trace.WithAttributes(producerAttributes(producer)...)) +} + +// StartKernelCallbackGetTelemetryConfig opens the span covering one +// GetTelemetryConfig call (kernel-callbacks.md's GetTelemetryConfig). +func (p *Provider) StartKernelCallbackGetTelemetryConfig(ctx context.Context, producer *commonv1.ProducerRef) (context.Context, trace.Span) { + return p.tracer.Start(ctx, spanNameKernelCallbackGetTelemetryConfig, trace.WithAttributes(producerAttributes(producer)...)) +} + +// StartKernelCallbackGetConfig opens the span covering one GetConfig call +// (kernel-callbacks.md's GetConfig). The span carries only producer +// attribution, never the config values themselves — GetConfig's own +// MUST NOT-echo rule applies to spans exactly as it does to logs. +func (p *Provider) StartKernelCallbackGetConfig(ctx context.Context, producer *commonv1.ProducerRef) (context.Context, trace.Span) { + return p.tracer.Start(ctx, spanNameKernelCallbackGetConfig, trace.WithAttributes(producerAttributes(producer)...)) +} + +// StartKernelCallbackPublish opens the span covering one Publish call +// (kernel-callbacks.md's Publish) — the RPC handler's own span, distinct +// from StartEventBusPublish, which covers the underlying internal/eventbus +// fan-out this handler calls into. +func (p *Provider) StartKernelCallbackPublish(ctx context.Context, producer *commonv1.ProducerRef) (context.Context, trace.Span) { + return p.tracer.Start(ctx, spanNameKernelCallbackPublish, trace.WithAttributes(producerAttributes(producer)...)) +} + +// StartKernelCallbackSubscribe opens the span covering one Subscribe +// stream's whole lifetime, from the initial call to the stream closing +// (kernel-callbacks.md's Subscribe) — a long-lived span, unlike this +// file's other RPC spans. +func (p *Provider) StartKernelCallbackSubscribe(ctx context.Context, producer *commonv1.ProducerRef) (context.Context, trace.Span) { + return p.tracer.Start(ctx, spanNameKernelCallbackSubscribe, trace.WithAttributes(producerAttributes(producer)...)) +} + +// StartKernelCallbackReadEvents opens the span covering one ReadEvents +// call (kernel-callbacks.md's ReadEvents). +func (p *Provider) StartKernelCallbackReadEvents(ctx context.Context, sessionID string, producer *commonv1.ProducerRef) (context.Context, trace.Span) { + attrs := append([]attribute.KeyValue{SessionIDKey.String(sessionID)}, producerAttributes(producer)...) + return p.tracer.Start(ctx, spanNameKernelCallbackReadEvents, trace.WithAttributes(attrs...)) +} + +// StartKernelCallbackGetSession opens the span covering one GetSession +// call (kernel-callbacks.md's GetSession). +func (p *Provider) StartKernelCallbackGetSession(ctx context.Context, sessionID string, producer *commonv1.ProducerRef) (context.Context, trace.Span) { + attrs := append([]attribute.KeyValue{SessionIDKey.String(sessionID)}, producerAttributes(producer)...) + return p.tracer.Start(ctx, spanNameKernelCallbackGetSession, trace.WithAttributes(attrs...)) +} + // EndSpan ends span, recording err onto it first if non-nil (RecordError // plus a codes.Error status) so a failed hook/tool/model call is visibly // distinguishable from a successful one in any trace viewer. Every Start* diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index d5022bf..d6a3dfd 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -6,6 +6,7 @@ import ( "fmt" "sync" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" otellog "go.opentelemetry.io/otel/log" lognoop "go.opentelemetry.io/otel/log/noop" "go.opentelemetry.io/otel/metric" @@ -47,6 +48,24 @@ type Backend interface { // log/slog output (internal/CLAUDE.md), into this backend. LogExporter(ctx context.Context) (sdklog.Exporter, error) + // TraceUploader returns an already-started otlptrace.Client for + // relaying a plugin's own already-completed spans + // (specifications/observability.md#the-relay-model) to this backend's + // collector, bypassing the SDK's TracerProvider/span-creation pipeline + // entirely — a relayed span already has its own trace_id/span_id/ + // timestamps from the plugin's own SDK, and re-creating it through + // this process's own tracer would silently reassign those, severing + // it from the parent/child relationships it already had. Unlike + // TraceExporter (which telemetry.New wraps in a + // sdktrace.TracerProvider this process starts and stops), the + // returned Client is not currently owned by Provider — the caller + // that requests it (internal/telemetryrelay) is responsible for + // calling Client.Stop when it's done, mirroring what + // otlptrace.Exporter would normally do internally. TraceUploader + // itself calls Client.Start before returning, so the returned Client + // is immediately ready for UploadTraces. + TraceUploader(ctx context.Context) (otlptrace.Client, error) + // Name identifies the driver, for error messages and diagnostics. Name() string } @@ -56,6 +75,8 @@ type Backend interface { // plugin's pkg/telemetry.Bootstrap) constructs via New and tears down via // Shutdown. type Provider struct { + cfg Config + tp *sdktrace.TracerProvider mp *sdkmetric.MeterProvider lp *sdklog.LoggerProvider @@ -70,8 +91,9 @@ type Provider struct { meterProvider metric.MeterProvider loggerProvider otellog.LoggerProvider - tracer trace.Tracer - instruments *Instruments + tracer trace.Tracer + instruments *Instruments + dynamicMetrics *dynamicMetrics shutdownOnce sync.Once shutdownErr error @@ -99,7 +121,7 @@ func New(ctx context.Context, cfg Config, backend Backend, producer *commonv1.Pr return nil, fmt.Errorf("telemetry: new: %w", err) } - p := &Provider{} + p := &Provider{cfg: cfg} if cfg.TracesEnabled { exporter, err := backend.TraceExporter(ctx) @@ -151,6 +173,7 @@ func New(ctx context.Context, cfg Config, backend Backend, producer *commonv1.Pr return nil, fmt.Errorf("telemetry: new: instruments: %w", err) } p.instruments = instruments + p.dynamicMetrics = newDynamicMetrics(p.meterProvider.Meter(meterName)) return p, nil } @@ -220,3 +243,12 @@ func (p *Provider) Tracer() trace.Tracer { func (p *Provider) Instruments() *Instruments { return p.instruments } + +// Config returns the Config this Provider was constructed with — used by +// internal/kernelcallback's GetTelemetryConfig handler to answer whether +// tracing/metrics/logs are enabled and at what sampling ratio +// (kernel-callbacks.md's GetTelemetryConfig), without threading a second +// copy of the same configuration through kernelcallback.Config. +func (p *Provider) Config() Config { + return p.cfg +} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 4da68e7..ccd5a49 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -29,6 +29,9 @@ func TestNew(t *testing.T) { if p.Instruments() == nil { t.Error("Instruments() = nil") } + if got := p.Config(); got.ServiceName != "test" { + t.Errorf("Config().ServiceName = %q, want test", got.ServiceName) + } if err := p.Shutdown(context.Background()); err != nil { t.Fatalf("Shutdown: %v", err) } diff --git a/internal/telemetryrelay/CLAUDE.md b/internal/telemetryrelay/CLAUDE.md new file mode 100644 index 0000000..15e99f8 --- /dev/null +++ b/internal/telemetryrelay/CLAUDE.md @@ -0,0 +1,13 @@ +# internal/telemetryrelay — agent notes + +- **This package never touches `internal/telemetry.Provider`'s own `sdktrace.TracerProvider`, on purpose.** A relayed span's `trace_id`/`span_id`/`parent_span_id`/timestamps are authored by the originating plugin's own OTel SDK, before it ever crosses the wire — re-creating it via `p.Tracer().Start(...)` would assign a *new* trace/span id, silently severing it from whatever parent/child relationships it already had. `convertSpan` (`convert.go`) is a pure, direct translation from the wire `trace.v1.Span` to the OTLP proto `tracepb.Span`, never a re-emission through this process's own tracer. Don't "simplify" this by routing a relayed span through `Provider.Tracer()` — it was considered and rejected for exactly this reason (see `docs/specifications/observability.md#span-relay-is-transparent`). + +- **OTLP groups spans by `(Resource, InstrumentationScope)`, not per-span — `groupByScope` (`relay.go`) exists because `trace.v1.Span` carries its own `Scope` field per span, but the wire format doesn't.** A batch relayed via one `ExportSpans` call can legitimately mix scopes (a plugin using more than one named tracer), so `Upload` buckets spans by `(scope.name, scope.version)` into separate `ScopeSpans` entries rather than assuming a batch is scope-homogeneous. Bucket order is first-seen, not sorted — deterministic across repeated calls with the same input, but not alphabetical; don't "fix" it into a sorted order expecting alignment with `structToKeyValues`' *key*-sorting (a different, unrelated ordering concern). + +- **`structToKeyValues`/`structValueToAnyValue` sort `Struct` keys before converting, purely for test/output determinism — this data is telemetry, not persisted state, so `.claude/rules/determinism.md`'s replay-ordering rule doesn't technically apply here.** Sorting was the cheaper, simpler choice over leaving map-iteration order to chance, not a compliance requirement. Don't read this as evidence that relayed spans participate in replay — `docs/specifications/observability.md#telemetry-never-replays-and-never-persists` is unchanged and unaffected by this package. + +- **`Relay.Upload` fails the whole batch on the first `convertSpan` error — there is no partial upload.** `groupByScope` calls `convertSpan` for every span before building any `ResourceSpans`, so a single malformed span (e.g. a non-hex `trace_id`) means `Upload` returns an error and `UploadTraces` is never called at all, rather than uploading everything except the bad one. This mirrors `ExportSpansRequest`'s own MUST-be-non-empty/well-formed framing in `kernel-callbacks.md` — unlike `Log`'s batch, which explicitly skips-and-warns a malformed entry, `ExportSpans` has no equivalent "warn and continue" carve-out in the spec, so don't add silent partial-success behavior here without a spec change first. + +- **`resourceForProducer` hardcodes the OTLP `"service.name"` resource key as a literal string instead of deriving it from `semconv.ServiceName(...).Key`.** Constructing a throwaway `attribute.KeyValue` just to read its `.Key` back out would be needless indirection for one of OTel's oldest, most stable semantic-convention keys. If a future OTel semconv release ever renames this constant, this literal would need a matching update — an acceptable tradeoff for the code-legibility gained here. + +- **A nil `producer` argument to `Upload` (or a nil `*commonv1.ProducerRef` reaching `resourceForProducer`) yields a valid, attribute-less `*resourcepb.Resource`, never a nil `Resource` or a panic.** In practice `internal/kernelcallback` always supplies the real producer identity it derived from the authenticated callback connection before calling `Upload` — the nil-safe path exists for robustness and for tests, not because a real caller is expected to omit it. diff --git a/internal/telemetryrelay/README.md b/internal/telemetryrelay/README.md new file mode 100644 index 0000000..b91952e --- /dev/null +++ b/internal/telemetryrelay/README.md @@ -0,0 +1,14 @@ +# internal/telemetryrelay + +The kernel side of the span-relay model (`docs/specifications/observability.md#the-relay-model`): a plugin relays its own completed trace spans to the kernel via `KernelCallbackService.ExportSpans` (`docs/specifications/kernel-callbacks.md`'s `ExportSpans`), and this package turns that batch into a real OTLP upload to the operator's configured collector. + +## What this package does + +- `convert.go` translates one `pluggableharness.trace.v1.Span` into its OTLP wire equivalent (`tracepb.Span`) — trace/span/parent-span ids from hex strings to raw bytes, the span-kind and status-code enums, timestamps to Unix-nanos, and a `google.protobuf.Struct` attribute set into OTLP `KeyValue`/`AnyValue` pairs (recursively, for a nested struct or list). +- `relay.go`'s `Relay` type groups a batch of spans into OTLP `ScopeSpans` by their declared `InstrumentationScope`, attaches a `Resource` built from the calling plugin's producer identity, and uploads the result via a wrapped `otlptrace.Client` — the same `Client` interface `internal/telemetry.Backend.TraceUploader` returns. + +## How it fits in + +`internal/telemetry`'s own `sdktrace.TracerProvider` is deliberately not involved: `sdktrace.ReadOnlySpan` can't be implemented outside the SDK, and re-creating a relayed span through the kernel's own tracer would assign it a fresh trace/span id, severing it from the parent/child relationships it already had in the plugin's own process. This package is the transparent bypass that lets a relayed span reach a collector with its original identity intact. + +A `Relay` is constructed per launched plugin (mirroring `internal/kernelcallback`'s "one `Server` per plugin instance" shape) from that plugin's own `Backend.TraceUploader()` result, and `internal/kernelcallback`'s `ExportSpans` handler calls `Relay.Upload` per RPC. diff --git a/internal/telemetryrelay/convert.go b/internal/telemetryrelay/convert.go new file mode 100644 index 0000000..8cfc3d4 --- /dev/null +++ b/internal/telemetryrelay/convert.go @@ -0,0 +1,218 @@ +package telemetryrelay + +import ( + "encoding/hex" + "fmt" + "sort" + + commonpb "go.opentelemetry.io/proto/otlp/common/v1" + tracepb "go.opentelemetry.io/proto/otlp/trace/v1" + "google.golang.org/protobuf/types/known/structpb" + + tracev1 "github.com/pluggableharness/agent/pkg/trace/proto/v1" +) + +// spanKindToProto maps trace.v1.SpanKind to its identically-shaped OTLP +// wire enum — the two enums are deliberately parallel (trace.v1's own +// package comment), so this is a direct value-for-value translation, not +// a lossy narrowing. +var spanKindToProto = map[tracev1.SpanKind]tracepb.Span_SpanKind{ + tracev1.SpanKind_SPAN_KIND_UNSPECIFIED: tracepb.Span_SPAN_KIND_UNSPECIFIED, + tracev1.SpanKind_SPAN_KIND_INTERNAL: tracepb.Span_SPAN_KIND_INTERNAL, + tracev1.SpanKind_SPAN_KIND_SERVER: tracepb.Span_SPAN_KIND_SERVER, + tracev1.SpanKind_SPAN_KIND_CLIENT: tracepb.Span_SPAN_KIND_CLIENT, + tracev1.SpanKind_SPAN_KIND_PRODUCER: tracepb.Span_SPAN_KIND_PRODUCER, + tracev1.SpanKind_SPAN_KIND_CONSUMER: tracepb.Span_SPAN_KIND_CONSUMER, +} + +// statusCodeToProto maps trace.v1.StatusCode to its identically-shaped +// OTLP wire enum. +var statusCodeToProto = map[tracev1.StatusCode]tracepb.Status_StatusCode{ + tracev1.StatusCode_STATUS_CODE_UNSPECIFIED: tracepb.Status_STATUS_CODE_UNSET, + tracev1.StatusCode_STATUS_CODE_OK: tracepb.Status_STATUS_CODE_OK, + tracev1.StatusCode_STATUS_CODE_ERROR: tracepb.Status_STATUS_CODE_ERROR, +} + +// decodeSpanID decodes a hex-encoded W3C trace/span id field. An empty +// string decodes to nil (OTLP's documented "unset" representation for an +// optional id, e.g. a root span's absent parent_span_id), never a +// zero-length-but-non-nil byte slice. +func decodeSpanID(field, hexStr string) ([]byte, error) { + if hexStr == "" { + return nil, nil + } + b, err := hex.DecodeString(hexStr) + if err != nil { + return nil, fmt.Errorf("telemetryrelay: convert: %s: %w", field, err) + } + return b, nil +} + +// convertSpan translates one wire Span into its OTLP proto equivalent. +// The kernel MUST NOT alter identity/timing fields in this translation — +// see doc.go and observability.md#the-relay-model. +func convertSpan(s *tracev1.Span) (*tracepb.Span, error) { + traceID, err := decodeSpanID("trace_id", s.GetTraceId()) + if err != nil { + return nil, err + } + spanID, err := decodeSpanID("span_id", s.GetSpanId()) + if err != nil { + return nil, err + } + var parentSpanID []byte + if s.ParentSpanId != nil { + parentSpanID, err = decodeSpanID("parent_span_id", s.GetParentSpanId()) + if err != nil { + return nil, err + } + } + + events, err := convertEvents(s.GetEvents()) + if err != nil { + return nil, err + } + links, err := convertLinks(s.GetLinks()) + if err != nil { + return nil, err + } + attrs, err := structToKeyValues(s.GetAttributes()) + if err != nil { + return nil, fmt.Errorf("telemetryrelay: convert: span attributes: %w", err) + } + + return &tracepb.Span{ + TraceId: traceID, + SpanId: spanID, + ParentSpanId: parentSpanID, + Name: s.GetName(), + Kind: spanKindToProto[s.GetKind()], + StartTimeUnixNano: uint64(s.GetStartTime().AsTime().UnixNano()), //nolint:gosec // wall-clock nanos never negative in practice + EndTimeUnixNano: uint64(s.GetEndTime().AsTime().UnixNano()), //nolint:gosec // wall-clock nanos never negative in practice + Attributes: attrs, + Events: events, + Links: links, + Status: convertStatus(s.GetStatus()), + }, nil +} + +func convertStatus(s *tracev1.Status) *tracepb.Status { + if s == nil { + return nil + } + return &tracepb.Status{ + Code: statusCodeToProto[s.GetCode()], + Message: s.GetMessage(), + } +} + +func convertEvents(events []*tracev1.SpanEvent) ([]*tracepb.Span_Event, error) { + if len(events) == 0 { + return nil, nil + } + out := make([]*tracepb.Span_Event, 0, len(events)) + for _, e := range events { + attrs, err := structToKeyValues(e.GetAttributes()) + if err != nil { + return nil, fmt.Errorf("telemetryrelay: convert: event %q attributes: %w", e.GetName(), err) + } + out = append(out, &tracepb.Span_Event{ + TimeUnixNano: uint64(e.GetTime().AsTime().UnixNano()), //nolint:gosec // wall-clock nanos never negative in practice + Name: e.GetName(), + Attributes: attrs, + }) + } + return out, nil +} + +func convertLinks(links []*tracev1.SpanLink) ([]*tracepb.Span_Link, error) { + if len(links) == 0 { + return nil, nil + } + out := make([]*tracepb.Span_Link, 0, len(links)) + for _, l := range links { + traceID, err := decodeSpanID("link trace_id", l.GetTraceId()) + if err != nil { + return nil, err + } + spanID, err := decodeSpanID("link span_id", l.GetSpanId()) + if err != nil { + return nil, err + } + attrs, err := structToKeyValues(l.GetAttributes()) + if err != nil { + return nil, fmt.Errorf("telemetryrelay: convert: link attributes: %w", err) + } + out = append(out, &tracepb.Span_Link{ + TraceId: traceID, + SpanId: spanID, + Attributes: attrs, + }) + } + return out, nil +} + +// structToKeyValues converts a google.protobuf.Struct into OTLP +// KeyValue/AnyValue pairs, sorted by key for deterministic output +// (.claude/rules/determinism.md's no-map-iteration-order rule — this +// package doesn't persist anything, but a stable translation makes this +// package's own tests, and any future golden-output comparison, reliable +// regardless of Go's randomized map iteration). +func structToKeyValues(s *structpb.Struct) ([]*commonpb.KeyValue, error) { + fields := s.GetFields() + if len(fields) == 0 { + return nil, nil + } + keys := make([]string, 0, len(fields)) + for k := range fields { + keys = append(keys, k) + } + sort.Strings(keys) + + out := make([]*commonpb.KeyValue, 0, len(keys)) + for _, k := range keys { + v, err := structValueToAnyValue(fields[k]) + if err != nil { + return nil, fmt.Errorf("telemetryrelay: convert: attribute %q: %w", k, err) + } + out = append(out, &commonpb.KeyValue{Key: k, Value: v}) + } + return out, nil +} + +// structValueToAnyValue converts one google.protobuf.Value into its OTLP +// AnyValue equivalent, recursively for a nested struct or list. +func structValueToAnyValue(v *structpb.Value) (*commonpb.AnyValue, error) { + if v == nil { + return nil, nil + } + switch kind := v.GetKind().(type) { + case *structpb.Value_NullValue, nil: + return &commonpb.AnyValue{}, nil + case *structpb.Value_BoolValue: + return &commonpb.AnyValue{Value: &commonpb.AnyValue_BoolValue{BoolValue: kind.BoolValue}}, nil + case *structpb.Value_NumberValue: + return &commonpb.AnyValue{Value: &commonpb.AnyValue_DoubleValue{DoubleValue: kind.NumberValue}}, nil + case *structpb.Value_StringValue: + return &commonpb.AnyValue{Value: &commonpb.AnyValue_StringValue{StringValue: kind.StringValue}}, nil + case *structpb.Value_StructValue: + kvs, err := structToKeyValues(kind.StructValue) + if err != nil { + return nil, err + } + return &commonpb.AnyValue{Value: &commonpb.AnyValue_KvlistValue{KvlistValue: &commonpb.KeyValueList{Values: kvs}}}, nil + case *structpb.Value_ListValue: + values := kind.ListValue.GetValues() + elems := make([]*commonpb.AnyValue, 0, len(values)) + for _, elem := range values { + converted, err := structValueToAnyValue(elem) + if err != nil { + return nil, err + } + elems = append(elems, converted) + } + return &commonpb.AnyValue{Value: &commonpb.AnyValue_ArrayValue{ArrayValue: &commonpb.ArrayValue{Values: elems}}}, nil + default: + return nil, fmt.Errorf("telemetryrelay: convert: unsupported structpb.Value kind %T", kind) + } +} diff --git a/internal/telemetryrelay/convert_test.go b/internal/telemetryrelay/convert_test.go new file mode 100644 index 0000000..24b2ce9 --- /dev/null +++ b/internal/telemetryrelay/convert_test.go @@ -0,0 +1,277 @@ +package telemetryrelay + +import ( + "testing" + "time" + + commonpb "go.opentelemetry.io/proto/otlp/common/v1" + tracepb "go.opentelemetry.io/proto/otlp/trace/v1" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + + tracev1 "github.com/pluggableharness/agent/pkg/trace/proto/v1" +) + +func TestDecodeSpanID(t *testing.T) { + t.Parallel() + + t.Run("empty is nil, not zero-length", func(t *testing.T) { + t.Parallel() + got, err := decodeSpanID("field", "") + if err != nil { + t.Fatalf("decodeSpanID: %v", err) + } + if got != nil { + t.Fatalf("decodeSpanID(\"\") = %v, want nil", got) + } + }) + + t.Run("valid hex", func(t *testing.T) { + t.Parallel() + got, err := decodeSpanID("field", "0102030405060708") + if err != nil { + t.Fatalf("decodeSpanID: %v", err) + } + want := []byte{1, 2, 3, 4, 5, 6, 7, 8} + if len(got) != len(want) { + t.Fatalf("decodeSpanID = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("decodeSpanID = %v, want %v", got, want) + } + } + }) + + t.Run("invalid hex errors", func(t *testing.T) { + t.Parallel() + if _, err := decodeSpanID("field", "not-hex"); err == nil { + t.Fatal("decodeSpanID(invalid) = nil error, want an error") + } + }) +} + +func TestConvertSpan_identityAndTimingPreserved(t *testing.T) { + t.Parallel() + + start := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC) + end := start.Add(2 * time.Second) + parentID := "0102030405060708" + + span := &tracev1.Span{ + TraceId: "0123456789abcdef0123456789abcdef", + SpanId: "fedcba9876543210", + ParentSpanId: &parentID, + Name: "tool.execute", + Kind: tracev1.SpanKind_SPAN_KIND_CLIENT, + StartTime: timestamppb.New(start), + EndTime: timestamppb.New(end), + Status: &tracev1.Status{Code: tracev1.StatusCode_STATUS_CODE_OK}, + Scope: &tracev1.InstrumentationScope{Name: "plugin.tool.github", Version: "1.0.0"}, + } + + got, err := convertSpan(span) + if err != nil { + t.Fatalf("convertSpan: %v", err) + } + + if got.Name != "tool.execute" { + t.Errorf("Name = %q, want tool.execute", got.Name) + } + if got.Kind != tracepb.Span_SPAN_KIND_CLIENT { + t.Errorf("Kind = %v, want SPAN_KIND_CLIENT", got.Kind) + } + if got.StartTimeUnixNano != uint64(start.UnixNano()) { + t.Errorf("StartTimeUnixNano = %d, want %d", got.StartTimeUnixNano, start.UnixNano()) + } + if got.EndTimeUnixNano != uint64(end.UnixNano()) { + t.Errorf("EndTimeUnixNano = %d, want %d", got.EndTimeUnixNano, end.UnixNano()) + } + if got.Status.Code != tracepb.Status_STATUS_CODE_OK { + t.Errorf("Status.Code = %v, want STATUS_CODE_OK", got.Status.Code) + } + if len(got.ParentSpanId) != 8 { + t.Errorf("ParentSpanId len = %d, want 8", len(got.ParentSpanId)) + } + if len(got.TraceId) != 16 { + t.Errorf("TraceId len = %d, want 16 (32 hex chars)", len(got.TraceId)) + } +} + +func TestConvertSpan_rootSpanHasNilParent(t *testing.T) { + t.Parallel() + + span := &tracev1.Span{ + TraceId: "0123456789abcdef0123456789abcdef", + SpanId: "fedcba9876543210", + Name: "session", + Kind: tracev1.SpanKind_SPAN_KIND_INTERNAL, + StartTime: timestamppb.Now(), + EndTime: timestamppb.Now(), + Status: &tracev1.Status{}, + } + + got, err := convertSpan(span) + if err != nil { + t.Fatalf("convertSpan: %v", err) + } + if got.ParentSpanId != nil { + t.Errorf("ParentSpanId = %v, want nil for a root span", got.ParentSpanId) + } +} + +func TestConvertSpan_invalidTraceIDErrors(t *testing.T) { + t.Parallel() + + span := &tracev1.Span{ + TraceId: "not-hex", + SpanId: "fedcba9876543210", + StartTime: timestamppb.Now(), + EndTime: timestamppb.Now(), + } + if _, err := convertSpan(span); err == nil { + t.Fatal("convertSpan(invalid trace_id) = nil error, want an error") + } +} + +func TestConvertEvents(t *testing.T) { + t.Parallel() + + events := []*tracev1.SpanEvent{ + {Name: "retry", Time: timestamppb.Now()}, + } + got, err := convertEvents(events) + if err != nil { + t.Fatalf("convertEvents: %v", err) + } + if len(got) != 1 || got[0].Name != "retry" { + t.Fatalf("convertEvents = %+v, want one event named retry", got) + } +} + +func TestConvertEvents_empty(t *testing.T) { + t.Parallel() + got, err := convertEvents(nil) + if err != nil { + t.Fatalf("convertEvents: %v", err) + } + if got != nil { + t.Fatalf("convertEvents(nil) = %v, want nil", got) + } +} + +func TestConvertLinks(t *testing.T) { + t.Parallel() + + links := []*tracev1.SpanLink{ + {TraceId: "0123456789abcdef0123456789abcdef", SpanId: "fedcba9876543210"}, + } + got, err := convertLinks(links) + if err != nil { + t.Fatalf("convertLinks: %v", err) + } + if len(got) != 1 || len(got[0].TraceId) != 16 || len(got[0].SpanId) != 8 { + t.Fatalf("convertLinks = %+v, want one link with 16-byte trace id / 8-byte span id", got) + } +} + +func TestConvertLinks_invalidSpanIDErrors(t *testing.T) { + t.Parallel() + links := []*tracev1.SpanLink{{TraceId: "0123456789abcdef0123456789abcdef", SpanId: "not-hex"}} + if _, err := convertLinks(links); err == nil { + t.Fatal("convertLinks(invalid span_id) = nil error, want an error") + } +} + +func TestStructToKeyValues(t *testing.T) { + t.Parallel() + + t.Run("empty", func(t *testing.T) { + t.Parallel() + got, err := structToKeyValues(nil) + if err != nil { + t.Fatalf("structToKeyValues: %v", err) + } + if got != nil { + t.Fatalf("structToKeyValues(nil) = %v, want nil", got) + } + }) + + t.Run("sorted by key regardless of map order", func(t *testing.T) { + t.Parallel() + s, err := structpb.NewStruct(map[string]any{ + "zebra": "z", + "alpha": "a", + "mid": "m", + }) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + got, err := structToKeyValues(s) + if err != nil { + t.Fatalf("structToKeyValues: %v", err) + } + if len(got) != 3 { + t.Fatalf("len(got) = %d, want 3", len(got)) + } + wantOrder := []string{"alpha", "mid", "zebra"} + for i, k := range wantOrder { + if got[i].Key != k { + t.Fatalf("got[%d].Key = %q, want %q", i, got[i].Key, k) + } + } + }) + + t.Run("every value kind converts", func(t *testing.T) { + t.Parallel() + s, err := structpb.NewStruct(map[string]any{ + "str": "hello", + "num": float64(42), + "flag": true, + "nested": map[string]any{"inner": "v"}, + "list": []any{"a", "b"}, + }) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + got, err := structToKeyValues(s) + if err != nil { + t.Fatalf("structToKeyValues: %v", err) + } + if len(got) != 5 { + t.Fatalf("len(got) = %d, want 5", len(got)) + } + + byKey := make(map[string]*commonpb.AnyValue, len(got)) + for _, kv := range got { + byKey[kv.Key] = kv.Value + } + + if byKey["str"].GetStringValue() != "hello" { + t.Errorf("str = %v, want hello", byKey["str"]) + } + if byKey["num"].GetDoubleValue() != 42 { + t.Errorf("num = %v, want 42", byKey["num"]) + } + if !byKey["flag"].GetBoolValue() { + t.Errorf("flag = %v, want true", byKey["flag"]) + } + if inner := byKey["nested"].GetKvlistValue(); inner == nil || len(inner.Values) != 1 || inner.Values[0].Key != "inner" { + t.Errorf("nested = %v, want a one-entry kvlist keyed \"inner\"", byKey["nested"]) + } + if list := byKey["list"].GetArrayValue(); list == nil || len(list.Values) != 2 { + t.Errorf("list = %v, want a two-element array", byKey["list"]) + } + }) +} + +func TestStructValueToAnyValue_nil(t *testing.T) { + t.Parallel() + got, err := structValueToAnyValue(nil) + if err != nil { + t.Fatalf("structValueToAnyValue(nil): %v", err) + } + if got != nil { + t.Fatalf("structValueToAnyValue(nil) = %v, want nil", got) + } +} diff --git a/internal/telemetryrelay/doc.go b/internal/telemetryrelay/doc.go new file mode 100644 index 0000000..23c01b8 --- /dev/null +++ b/internal/telemetryrelay/doc.go @@ -0,0 +1,22 @@ +// Package telemetryrelay implements the kernel side of the span-relay +// model described in docs/specifications/observability.md#the-relay-model: +// translating a batch of plugin-relayed pluggableharness.trace.v1.Span +// messages (kernel-callbacks.md's ExportSpans) into OTLP +// tracepb.ResourceSpans and uploading them via an otlptrace.Client. +// +// This bypasses internal/telemetry's own sdktrace.TracerProvider +// entirely, on purpose: sdktrace.ReadOnlySpan is unimplementable outside +// the SDK (an unexported method), and re-creating a relayed span through +// this process's own tracer would assign it a fresh trace_id/span_id, +// silently severing it from the parent/child relationships it already +// had in the originating plugin's own process. Relay.Upload therefore +// translates the wire Span directly into the OTLP wire format and hands +// it to internal/telemetry.Backend's TraceUploader-returned Client, +// unmodified in every identity/timing field. +// +// A Relay is not owned by internal/telemetry.Provider — internal/ +// kernelcallback constructs one per launched plugin (mirroring +// internal/kernelcallback's own "one Server per plugin instance" shape) +// from that plugin's Backend.TraceUploader() Client, and is responsible +// for calling Client.Stop when the plugin's callback connection closes. +package telemetryrelay diff --git a/internal/telemetryrelay/relay.go b/internal/telemetryrelay/relay.go new file mode 100644 index 0000000..ceec245 --- /dev/null +++ b/internal/telemetryrelay/relay.go @@ -0,0 +1,141 @@ +package telemetryrelay + +import ( + "context" + "fmt" + + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" + commonpb "go.opentelemetry.io/proto/otlp/common/v1" + resourcepb "go.opentelemetry.io/proto/otlp/resource/v1" + tracepb "go.opentelemetry.io/proto/otlp/trace/v1" + + "github.com/pluggableharness/agent/internal/telemetry" + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + tracev1 "github.com/pluggableharness/agent/pkg/trace/proto/v1" +) + +// serviceNameAttrKey is OTel's own resource semantic-convention key for a +// process's service name. Hardcoded rather than built via +// semconv.ServiceName(...).Key (which would require constructing a +// throwaway attribute.KeyValue just to read its Key back out) — this key +// name is one of OTel's oldest, most stable resource conventions. +const serviceNameAttrKey = "service.name" + +// Relay translates plugin-relayed trace.v1.Span batches into OTLP +// ResourceSpans and uploads them via an otlptrace.Client — see doc.go. +type Relay struct { + client otlptrace.Client +} + +// New returns a Relay uploading through client (typically +// telemetry.Backend.TraceUploader's result for the plugin this Relay is +// dedicated to). +func New(client otlptrace.Client) *Relay { + return &Relay{client: client} +} + +// Upload translates spans into one ResourceSpans batch — grouped into +// one ScopeSpans per distinct InstrumentationScope a span declares, since +// OTLP's wire structure groups spans by (Resource, Scope) rather than +// carrying scope per span — stamped with producer's resource attributes +// (the same attribute.Key vocabulary internal/telemetry.BuildResource +// uses, so a relayed span's resource is indistinguishable from a directly +// exported one), and uploads it via the wrapped otlptrace.Client. Upload +// is a no-op returning nil for an empty spans slice — an ExportSpans +// caller that filters down to nothing has nothing to relay, not an error. +func (r *Relay) Upload(ctx context.Context, spans []*tracev1.Span, producer *commonv1.ProducerRef) error { + if len(spans) == 0 { + return nil + } + + scopeSpans, err := groupByScope(spans) + if err != nil { + return fmt.Errorf("telemetryrelay: upload: %w", err) + } + + rs := &tracepb.ResourceSpans{ + Resource: resourceForProducer(producer), + ScopeSpans: scopeSpans, + } + if err := r.client.UploadTraces(ctx, []*tracepb.ResourceSpans{rs}); err != nil { + return fmt.Errorf("telemetryrelay: upload: %w", err) + } + return nil +} + +// Stop releases the wrapped otlptrace.Client's connection. The caller +// that constructed this Relay (internal/kernelcallback, per plugin) owns +// calling this once, when the plugin's callback connection closes — see +// doc.go. +func (r *Relay) Stop(ctx context.Context) error { + return r.client.Stop(ctx) +} + +// scopeKey identifies one distinct InstrumentationScope for grouping. +type scopeKey struct { + name string + version string +} + +// groupByScope buckets spans into one *tracepb.ScopeSpans per distinct +// (name, version) InstrumentationScope, in first-seen order — stable +// across calls with the same input, since it never depends on Go's +// randomized map iteration for output ordering (.claude/rules/determinism.md). +func groupByScope(spans []*tracev1.Span) ([]*tracepb.ScopeSpans, error) { + order := make([]scopeKey, 0) + buckets := make(map[scopeKey]*tracepb.ScopeSpans) + + for _, s := range spans { + converted, err := convertSpan(s) + if err != nil { + return nil, err + } + + key := scopeKey{name: s.GetScope().GetName(), version: s.GetScope().GetVersion()} + bucket, ok := buckets[key] + if !ok { + bucket = &tracepb.ScopeSpans{ + Scope: &commonpb.InstrumentationScope{Name: key.name, Version: key.version}, + } + buckets[key] = bucket + order = append(order, key) + } + bucket.Spans = append(bucket.Spans, converted) + } + + out := make([]*tracepb.ScopeSpans, 0, len(order)) + for _, key := range order { + out = append(out, buckets[key]) + } + return out, nil +} + +// resourceForProducer builds the OTLP Resource for a batch relayed on +// producer's behalf, using the exact attribute.Key vocabulary +// internal/telemetry.BuildResource uses for a directly-exported process's +// own Resource (ProducerCategoryKey/ProducerNameKey/ProducerVersionKey), +// so a relayed span's resource is indistinguishable from one that process +// exported itself. producer is never nil in practice — the kernel derives +// it from the authenticated callback connection before calling Upload — +// but a nil producer still yields a valid, attribute-less Resource rather +// than panicking. +func resourceForProducer(producer *commonv1.ProducerRef) *resourcepb.Resource { + if producer == nil { + return &resourcepb.Resource{} + } + return &resourcepb.Resource{ + Attributes: []*commonpb.KeyValue{ + stringKV(serviceNameAttrKey, producer.GetName()), + stringKV(string(telemetry.ProducerCategoryKey), producer.GetCategory().String()), + stringKV(string(telemetry.ProducerNameKey), producer.GetName()), + stringKV(string(telemetry.ProducerVersionKey), producer.GetVersion()), + }, + } +} + +func stringKV(key, value string) *commonpb.KeyValue { + return &commonpb.KeyValue{ + Key: key, + Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_StringValue{StringValue: value}}, + } +} diff --git a/internal/telemetryrelay/relay_test.go b/internal/telemetryrelay/relay_test.go new file mode 100644 index 0000000..906783f --- /dev/null +++ b/internal/telemetryrelay/relay_test.go @@ -0,0 +1,172 @@ +package telemetryrelay_test + +import ( + "context" + "testing" + "time" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/pluggableharness/agent/internal/telemetry/drivers/fake" + "github.com/pluggableharness/agent/internal/telemetryrelay" + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + tracev1 "github.com/pluggableharness/agent/pkg/trace/proto/v1" +) + +func testSpan(t *testing.T, name string) *tracev1.Span { + t.Helper() + return &tracev1.Span{ + TraceId: "0123456789abcdef0123456789abcdef", + SpanId: "fedcba9876543210", + Name: name, + Kind: tracev1.SpanKind_SPAN_KIND_INTERNAL, + StartTime: timestamppb.Now(), + EndTime: timestamppb.New(time.Now().Add(time.Second)), + Status: &tracev1.Status{Code: tracev1.StatusCode_STATUS_CODE_OK}, + Scope: &tracev1.InstrumentationScope{Name: "plugin.tool.github", Version: "1.0.0"}, + } +} + +func TestRelay_upload(t *testing.T) { + t.Parallel() + + recorder := fake.NewRelayedSpansRecorder() + relay := telemetryrelay.New(recorder) + + producer := &commonv1.ProducerRef{ + Category: commonv1.Category_CATEGORY_TOOL, + Name: "github", + Version: "1.2.3", + } + spans := []*tracev1.Span{testSpan(t, "tool.execute")} + + if err := relay.Upload(context.Background(), spans, producer); err != nil { + t.Fatalf("Upload: %v", err) + } + + got := recorder.ResourceSpans() + if len(got) != 1 { + t.Fatalf("len(ResourceSpans()) = %d, want 1", len(got)) + } + rs := got[0] + if rs.Resource == nil { + t.Fatal("Resource is nil") + } + + attrs := make(map[string]string, len(rs.Resource.Attributes)) + for _, kv := range rs.Resource.Attributes { + attrs[kv.Key] = kv.Value.GetStringValue() + } + if attrs["service.name"] != "github" { + t.Errorf("service.name = %q, want github", attrs["service.name"]) + } + if attrs["pluggableharness.producer.name"] != "github" { + t.Errorf("pluggableharness.producer.name = %q, want github", attrs["pluggableharness.producer.name"]) + } + if attrs["pluggableharness.producer.version"] != "1.2.3" { + t.Errorf("pluggableharness.producer.version = %q, want 1.2.3", attrs["pluggableharness.producer.version"]) + } + + if len(rs.ScopeSpans) != 1 { + t.Fatalf("len(ScopeSpans) = %d, want 1", len(rs.ScopeSpans)) + } + if rs.ScopeSpans[0].Scope.Name != "plugin.tool.github" { + t.Errorf("Scope.Name = %q, want plugin.tool.github", rs.ScopeSpans[0].Scope.Name) + } + if len(rs.ScopeSpans[0].Spans) != 1 || rs.ScopeSpans[0].Spans[0].Name != "tool.execute" { + t.Fatalf("ScopeSpans[0].Spans = %+v, want one span named tool.execute", rs.ScopeSpans[0].Spans) + } +} + +func TestRelay_upload_groupsByScope(t *testing.T) { + t.Parallel() + + recorder := fake.NewRelayedSpansRecorder() + relay := telemetryrelay.New(recorder) + + a := testSpan(t, "a") + a.Scope = &tracev1.InstrumentationScope{Name: "scope.a"} + b := testSpan(t, "b") + b.Scope = &tracev1.InstrumentationScope{Name: "scope.b"} + a2 := testSpan(t, "a2") + a2.Scope = &tracev1.InstrumentationScope{Name: "scope.a"} + + if err := relay.Upload(context.Background(), []*tracev1.Span{a, b, a2}, nil); err != nil { + t.Fatalf("Upload: %v", err) + } + + got := recorder.ResourceSpans() + if len(got) != 1 { + t.Fatalf("len(ResourceSpans()) = %d, want 1", len(got)) + } + scopeSpans := got[0].ScopeSpans + if len(scopeSpans) != 2 { + t.Fatalf("len(ScopeSpans) = %d, want 2 (scope.a, scope.b)", len(scopeSpans)) + } + if scopeSpans[0].Scope.Name != "scope.a" || len(scopeSpans[0].Spans) != 2 { + t.Errorf("scope.a bucket = %+v, want 2 spans (a, a2) in first-seen order", scopeSpans[0]) + } + if scopeSpans[1].Scope.Name != "scope.b" || len(scopeSpans[1].Spans) != 1 { + t.Errorf("scope.b bucket = %+v, want 1 span (b)", scopeSpans[1]) + } +} + +func TestRelay_upload_emptyIsNoop(t *testing.T) { + t.Parallel() + + recorder := fake.NewRelayedSpansRecorder() + relay := telemetryrelay.New(recorder) + + if err := relay.Upload(context.Background(), nil, nil); err != nil { + t.Fatalf("Upload(nil spans): %v", err) + } + if got := recorder.ResourceSpans(); len(got) != 0 { + t.Fatalf("ResourceSpans() = %v, want empty for a no-op Upload", got) + } +} + +func TestRelay_upload_nilProducerYieldsEmptyResource(t *testing.T) { + t.Parallel() + + recorder := fake.NewRelayedSpansRecorder() + relay := telemetryrelay.New(recorder) + + if err := relay.Upload(context.Background(), []*tracev1.Span{testSpan(t, "x")}, nil); err != nil { + t.Fatalf("Upload: %v", err) + } + got := recorder.ResourceSpans() + if len(got) != 1 { + t.Fatalf("len(ResourceSpans()) = %d, want 1", len(got)) + } + if len(got[0].Resource.GetAttributes()) != 0 { + t.Errorf("Resource.Attributes = %v, want empty for a nil producer", got[0].Resource.GetAttributes()) + } +} + +func TestRelay_upload_invalidSpanErrors(t *testing.T) { + t.Parallel() + + recorder := fake.NewRelayedSpansRecorder() + relay := telemetryrelay.New(recorder) + + bad := testSpan(t, "bad") + bad.TraceId = "not-hex" + + if err := relay.Upload(context.Background(), []*tracev1.Span{bad}, nil); err == nil { + t.Fatal("Upload(invalid span) = nil error, want an error") + } + if got := recorder.ResourceSpans(); len(got) != 0 { + t.Fatalf("ResourceSpans() = %v, want empty — a conversion failure must not partially upload", got) + } +} + +func TestRelay_stop(t *testing.T) { + t.Parallel() + + recorder := fake.NewRelayedSpansRecorder() + relay := telemetryrelay.New(recorder) + + if err := relay.Stop(context.Background()); err != nil { + t.Errorf("Stop: %v", err) + } +} diff --git a/mkdocs.yml b/mkdocs.yml index 0e0c421..25f6056 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,7 +1,7 @@ site_name: PluggableHarness Agent site_url: https://docs.pluggableharness.ai/ site_description: >- - The AI coding harness you never have to fork. A small Go microkernel, six + The AI coding harness you never have to fork. A small Go microkernel, seven out-of-process plugin categories, one config file — protocol specifications and the first-party catalog. repo_url: https://github.com/pluggableharness/agent @@ -126,10 +126,10 @@ plugins: - llmstxt: markdown_description: >- PluggableHarness Agent is a microkernel AI coding harness: a small Go - kernel plus six out-of-process plugin categories (model, tool, context, - memory, frontend, widget) declared in agent.hcl. These documents are - the authoritative protocol specifications and the first-party - provider/tool catalog. + kernel plus seven out-of-process plugin categories (model, tool, context, + memory, frontend, widget, slashcommand) declared in agent.hcl. These + documents are the authoritative protocol specifications and the + first-party provider/tool catalog. full_output: llms-full.txt sections: Specifications: @@ -141,6 +141,7 @@ plugins: - specifications/context/*.md - specifications/memory/*.md - specifications/frontend/*.md + - specifications/slashcommand/*.md First-party catalog: - first-party/*.md - first-party/providers/*.md @@ -155,6 +156,8 @@ nav: - Architecture: specifications/architecture.md - Kernel contracts: - Kernel callbacks: specifications/kernel-callbacks.md + - Event bus: specifications/event-bus.md + - Observability: specifications/observability.md - State backend: specifications/state-backend.md - Agent loop: - specifications/agent-loop/README.md @@ -206,6 +209,12 @@ nav: - Render tree: specifications/frontend/render-tree.md - Examples: specifications/frontend/examples.md - Conformance: specifications/frontend/conformance.md + - Slashcommand provider protocol: + - specifications/slashcommand/README.md + - Protocol: specifications/slashcommand/protocol.md + - Data types: specifications/slashcommand/data-types.md + - Examples: specifications/slashcommand/examples.md + - Conformance: specifications/slashcommand/conformance.md - First-party: - first-party/index.md - Model providers: diff --git a/pkg/common/plugin.go b/pkg/common/plugin.go index 536179a..cc146c6 100644 --- a/pkg/common/plugin.go +++ b/pkg/common/plugin.go @@ -1,6 +1,6 @@ // Package common holds the hand-written, cross-category glue that every // PluggableHarness Agent plugin category (provider, tool, context, memory, frontend, -// widget) and the kernel-side plugin runtime compile against identically: +// widget, slashcommand) and the kernel-side plugin runtime compile against identically: // the go-plugin handshake, the shared callback broker ID, and small helpers // derived from the generated pluggableharness.common.v1 types in ./proto/v1. It is // deliberately tiny — anything category-specific belongs in that category's @@ -26,7 +26,7 @@ const ( magicCookieValue = "pluggableharness-agent-v1-a6f3c9d2-plugin-handshake" ) -// Handshake is the single plugin.HandshakeConfig every one of the six +// Handshake is the single plugin.HandshakeConfig every one of the seven // plugin categories MUST share — one magic cookie, one ProtocolVersion // field. Different categories MUST NOT be given different cookies // (.claude/rules/plugin-runtime.md). @@ -40,7 +40,7 @@ var Handshake = plugin.HandshakeConfig{ // on which the kernel serves KernelCallbackService back to every launched // plugin, and the plugin dials to reach it. It is fixed (not // wire-negotiated) because no ConfigureRequest — or any other message, in -// any of the six category protos — carries a broker-ID field (confirmed by +// any of the seven category protos — carries a broker-ID field (confirmed by // direct proto read during design). A fixed, out-of-band constant both // sides compile against removes the need for one, the same way the magic // cookie above is agreed out-of-band rather than negotiated. Since the @@ -70,6 +70,8 @@ func PluginKey(c commonv1.Category) string { return "frontend" case commonv1.Category_CATEGORY_WIDGET: return "widget" + case commonv1.Category_CATEGORY_SLASHCOMMAND: + return "slashcommand" default: return strings.ToLower(strings.TrimPrefix(c.String(), "CATEGORY_")) } diff --git a/pkg/common/plugin_test.go b/pkg/common/plugin_test.go index a79895c..bbbbbbd 100644 --- a/pkg/common/plugin_test.go +++ b/pkg/common/plugin_test.go @@ -41,6 +41,7 @@ func TestPluginKey(t *testing.T) { {"memory", commonv1.Category_CATEGORY_MEMORY, "memory"}, {"frontend", commonv1.Category_CATEGORY_FRONTEND, "frontend"}, {"widget", commonv1.Category_CATEGORY_WIDGET, "widget"}, + {"slashcommand", commonv1.Category_CATEGORY_SLASHCOMMAND, "slashcommand"}, } seen := make(map[string]commonv1.Category, len(tests)) diff --git a/pkg/common/proto/v1/common.pb.go b/pkg/common/proto/v1/types.pb.go similarity index 67% rename from pkg/common/proto/v1/common.pb.go rename to pkg/common/proto/v1/types.pb.go index 9c5a54b..47a998f 100644 --- a/pkg/common/proto/v1/common.pb.go +++ b/pkg/common/proto/v1/types.pb.go @@ -2,7 +2,7 @@ // versions: // protoc-gen-go v1.36.11 // protoc (unknown) -// source: pluggableharness/common/v1/common.proto +// source: pluggableharness/common/v1/types.proto // Package pluggableharness.common.v1 defines cross-cutting metadata and identity // types shared by every plugin category's protocol. It has no RPCs of its @@ -29,7 +29,7 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -// Category identifies which of the six plugin categories a producer +// Category identifies which of the seven plugin categories a producer // implements. Used wherever a message needs to refer to "a producer of any // kind" generically — e.g. the kernel's producer registry and the // state-backend's producers table (specifications/state-backend.md §4). @@ -51,6 +51,8 @@ const ( Category_CATEGORY_FRONTEND Category = 5 // A widget provider — specifications/frontend.md §4. Category_CATEGORY_WIDGET Category = 6 + // A slashcommand provider — specifications/slashcommand/. + Category_CATEGORY_SLASHCOMMAND Category = 7 ) // Enum value maps for Category. @@ -63,15 +65,17 @@ var ( 4: "CATEGORY_MEMORY", 5: "CATEGORY_FRONTEND", 6: "CATEGORY_WIDGET", + 7: "CATEGORY_SLASHCOMMAND", } Category_value = map[string]int32{ - "CATEGORY_UNSPECIFIED": 0, - "CATEGORY_MODEL": 1, - "CATEGORY_TOOL": 2, - "CATEGORY_CONTEXT": 3, - "CATEGORY_MEMORY": 4, - "CATEGORY_FRONTEND": 5, - "CATEGORY_WIDGET": 6, + "CATEGORY_UNSPECIFIED": 0, + "CATEGORY_MODEL": 1, + "CATEGORY_TOOL": 2, + "CATEGORY_CONTEXT": 3, + "CATEGORY_MEMORY": 4, + "CATEGORY_FRONTEND": 5, + "CATEGORY_WIDGET": 6, + "CATEGORY_SLASHCOMMAND": 7, } ) @@ -86,11 +90,11 @@ func (x Category) String() string { } func (Category) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_common_v1_common_proto_enumTypes[0].Descriptor() + return file_pluggableharness_common_v1_types_proto_enumTypes[0].Descriptor() } func (Category) Type() protoreflect.EnumType { - return &file_pluggableharness_common_v1_common_proto_enumTypes[0] + return &file_pluggableharness_common_v1_types_proto_enumTypes[0] } func (x Category) Number() protoreflect.EnumNumber { @@ -99,7 +103,7 @@ func (x Category) Number() protoreflect.EnumNumber { // Deprecated: Use Category.Descriptor instead. func (Category) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_common_v1_common_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_common_v1_types_proto_rawDescGZIP(), []int{0} } // HookPoint identifies one of the eight dispatchable points in the agent @@ -183,11 +187,11 @@ func (x HookPoint) String() string { } func (HookPoint) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_common_v1_common_proto_enumTypes[1].Descriptor() + return file_pluggableharness_common_v1_types_proto_enumTypes[1].Descriptor() } func (HookPoint) Type() protoreflect.EnumType { - return &file_pluggableharness_common_v1_common_proto_enumTypes[1] + return &file_pluggableharness_common_v1_types_proto_enumTypes[1] } func (x HookPoint) Number() protoreflect.EnumNumber { @@ -196,7 +200,7 @@ func (x HookPoint) Number() protoreflect.EnumNumber { // Deprecated: Use HookPoint.Descriptor instead. func (HookPoint) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_common_v1_common_proto_rawDescGZIP(), []int{1} + return file_pluggableharness_common_v1_types_proto_rawDescGZIP(), []int{1} } // ProducerRef identifies the exact plugin build that produced something: a @@ -220,7 +224,7 @@ type ProducerRef struct { // "github.com/agentco/anthropic-provider". Matches the address format // used in agent.hcl's provider {} and required_providers {} blocks. Source string `protobuf:"bytes,3,opt,name=source,proto3" json:"source,omitempty"` - // Which of the six plugin categories this producer implements. + // Which of the seven plugin categories this producer implements. Category Category `protobuf:"varint,4,opt,name=category,proto3,enum=pluggableharness.common.v1.Category" json:"category,omitempty"` // The go-plugin handshake protocol version this producer build was // compiled against (.claude/rules/plugin-runtime.md). A version bump @@ -233,7 +237,7 @@ type ProducerRef struct { func (x *ProducerRef) Reset() { *x = ProducerRef{} - mi := &file_pluggableharness_common_v1_common_proto_msgTypes[0] + mi := &file_pluggableharness_common_v1_types_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -245,7 +249,7 @@ func (x *ProducerRef) String() string { func (*ProducerRef) ProtoMessage() {} func (x *ProducerRef) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_common_v1_common_proto_msgTypes[0] + mi := &file_pluggableharness_common_v1_types_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -258,7 +262,7 @@ func (x *ProducerRef) ProtoReflect() protoreflect.Message { // Deprecated: Use ProducerRef.ProtoReflect.Descriptor instead. func (*ProducerRef) Descriptor() ([]byte, []int) { - return file_pluggableharness_common_v1_common_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_common_v1_types_proto_rawDescGZIP(), []int{0} } func (x *ProducerRef) GetName() string { @@ -305,7 +309,7 @@ func (x *ProducerRef) GetProtocolVersion() uint32 { // ProviderRef when only the logical identity does. type ProviderRef struct { state protoimpl.MessageState `protogen:"open.v1"` - // Which of the six plugin categories this reference names. + // Which of the seven plugin categories this reference names. Category Category `protobuf:"varint,1,opt,name=category,proto3,enum=pluggableharness.common.v1.Category" json:"category,omitempty"` // The plugin's declared name, e.g. "anthropic". Unique within a category, // not globally — matches ProducerRef.name's uniqueness scope. @@ -316,7 +320,7 @@ type ProviderRef struct { func (x *ProviderRef) Reset() { *x = ProviderRef{} - mi := &file_pluggableharness_common_v1_common_proto_msgTypes[1] + mi := &file_pluggableharness_common_v1_types_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -328,7 +332,7 @@ func (x *ProviderRef) String() string { func (*ProviderRef) ProtoMessage() {} func (x *ProviderRef) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_common_v1_common_proto_msgTypes[1] + mi := &file_pluggableharness_common_v1_types_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -341,7 +345,7 @@ func (x *ProviderRef) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderRef.ProtoReflect.Descriptor instead. func (*ProviderRef) Descriptor() ([]byte, []int) { - return file_pluggableharness_common_v1_common_proto_rawDescGZIP(), []int{1} + return file_pluggableharness_common_v1_types_proto_rawDescGZIP(), []int{1} } func (x *ProviderRef) GetCategory() Category { @@ -381,7 +385,7 @@ type CallContext struct { func (x *CallContext) Reset() { *x = CallContext{} - mi := &file_pluggableharness_common_v1_common_proto_msgTypes[2] + mi := &file_pluggableharness_common_v1_types_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -393,7 +397,7 @@ func (x *CallContext) String() string { func (*CallContext) ProtoMessage() {} func (x *CallContext) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_common_v1_common_proto_msgTypes[2] + mi := &file_pluggableharness_common_v1_types_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -406,7 +410,7 @@ func (x *CallContext) ProtoReflect() protoreflect.Message { // Deprecated: Use CallContext.ProtoReflect.Descriptor instead. func (*CallContext) Descriptor() ([]byte, []int) { - return file_pluggableharness_common_v1_common_proto_rawDescGZIP(), []int{2} + return file_pluggableharness_common_v1_types_proto_rawDescGZIP(), []int{2} } func (x *CallContext) GetSessionId() string { @@ -430,11 +434,93 @@ func (x *CallContext) GetWorkingDirectory() string { return "" } -var File_pluggableharness_common_v1_common_proto protoreflect.FileDescriptor +// PromptExpansionSpec declares a static template-expansion slash +// command: never executes anything, the kernel expands `template` with +// the user's arguments and submits the result as an ordinary +// user_message, costing a model turn. Declarable directly in any +// category's own capability response (model.md §2 Capabilities, tool.md +// §2 GetSchemaResponse, context.md §2 ContextCapabilities, memory.md §3 +// MemoryCapabilities, frontend.md §2 FrontendCapabilities). Lives here +// rather than in slashcommand.v1 specifically because it has zero +// dependency on that package's own vocabulary (kind/risk/concurrency, +// borrowed from tool.v1) — homing it in the import-nothing leaf avoids +// giving every embedding category's package an edge into slashcommand.v1 +// merely to declare a field that never invokes anything. A +// directly-invocable slash command is a different, tool-shaped thing — +// see pluggableharness.slashcommand.v1.SlashCommandSpec. +type PromptExpansionSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The command's name, without the leading "/". MUST be unique across + // every prompt-expansion command declared by every provider in the + // session — a name collision at config-load time is a hard error + // (configuration.md §5), independent of the direct-invoke namespace + // pluggableharness.slashcommand.v1.SlashCommandSpec.name occupies. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Shown in the frontend's hotkey_hints region and wherever else the + // frontend surfaces available commands. + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + // The prompt template to expand, using "{arg}"-style placeholders. + Template string `protobuf:"bytes,3,opt,name=template,proto3" json:"template,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PromptExpansionSpec) Reset() { + *x = PromptExpansionSpec{} + mi := &file_pluggableharness_common_v1_types_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PromptExpansionSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PromptExpansionSpec) ProtoMessage() {} + +func (x *PromptExpansionSpec) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_common_v1_types_proto_msgTypes[3] + 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 PromptExpansionSpec.ProtoReflect.Descriptor instead. +func (*PromptExpansionSpec) Descriptor() ([]byte, []int) { + return file_pluggableharness_common_v1_types_proto_rawDescGZIP(), []int{3} +} + +func (x *PromptExpansionSpec) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *PromptExpansionSpec) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *PromptExpansionSpec) GetTemplate() string { + if x != nil { + return x.Template + } + return "" +} + +var File_pluggableharness_common_v1_types_proto protoreflect.FileDescriptor -const file_pluggableharness_common_v1_common_proto_rawDesc = "" + +const file_pluggableharness_common_v1_types_proto_rawDesc = "" + "\n" + - "'pluggableharness/common/v1/common.proto\x12\x1apluggableharness.common.v1\"\xc0\x01\n" + + "&pluggableharness/common/v1/types.proto\x12\x1apluggableharness.common.v1\"\xc0\x01\n" + "\vProducerRef\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\aversion\x18\x02 \x01(\tR\aversion\x12\x16\n" + @@ -448,7 +534,11 @@ const file_pluggableharness_common_v1_common_proto_rawDesc = "" + "\n" + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x17\n" + "\aturn_id\x18\x02 \x01(\tR\x06turnId\x12+\n" + - "\x11working_directory\x18\x03 \x01(\tR\x10workingDirectory*\xa2\x01\n" + + "\x11working_directory\x18\x03 \x01(\tR\x10workingDirectory\"g\n" + + "\x13PromptExpansionSpec\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x1a\n" + + "\btemplate\x18\x03 \x01(\tR\btemplate*\xbd\x01\n" + "\bCategory\x12\x18\n" + "\x14CATEGORY_UNSPECIFIED\x10\x00\x12\x12\n" + "\x0eCATEGORY_MODEL\x10\x01\x12\x11\n" + @@ -456,7 +546,8 @@ const file_pluggableharness_common_v1_common_proto_rawDesc = "" + "\x10CATEGORY_CONTEXT\x10\x03\x12\x13\n" + "\x0fCATEGORY_MEMORY\x10\x04\x12\x15\n" + "\x11CATEGORY_FRONTEND\x10\x05\x12\x13\n" + - "\x0fCATEGORY_WIDGET\x10\x06*\x97\x02\n" + + "\x0fCATEGORY_WIDGET\x10\x06\x12\x19\n" + + "\x15CATEGORY_SLASHCOMMAND\x10\a*\x97\x02\n" + "\tHookPoint\x12\x1a\n" + "\x16HOOK_POINT_UNSPECIFIED\x10\x00\x12\x1c\n" + "\x18HOOK_POINT_SESSION_START\x10\x01\x12\x1d\n" + @@ -469,27 +560,28 @@ const file_pluggableharness_common_v1_common_proto_rawDesc = "" + "\x16HOOK_POINT_SESSION_END\x10\bB@Z>github.com/pluggableharness/agent/pkg/common/proto/v1;commonv1b\x06proto3" var ( - file_pluggableharness_common_v1_common_proto_rawDescOnce sync.Once - file_pluggableharness_common_v1_common_proto_rawDescData []byte + file_pluggableharness_common_v1_types_proto_rawDescOnce sync.Once + file_pluggableharness_common_v1_types_proto_rawDescData []byte ) -func file_pluggableharness_common_v1_common_proto_rawDescGZIP() []byte { - file_pluggableharness_common_v1_common_proto_rawDescOnce.Do(func() { - file_pluggableharness_common_v1_common_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_common_v1_common_proto_rawDesc), len(file_pluggableharness_common_v1_common_proto_rawDesc))) +func file_pluggableharness_common_v1_types_proto_rawDescGZIP() []byte { + file_pluggableharness_common_v1_types_proto_rawDescOnce.Do(func() { + file_pluggableharness_common_v1_types_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_common_v1_types_proto_rawDesc), len(file_pluggableharness_common_v1_types_proto_rawDesc))) }) - return file_pluggableharness_common_v1_common_proto_rawDescData + return file_pluggableharness_common_v1_types_proto_rawDescData } -var file_pluggableharness_common_v1_common_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_pluggableharness_common_v1_common_proto_msgTypes = make([]protoimpl.MessageInfo, 3) -var file_pluggableharness_common_v1_common_proto_goTypes = []any{ - (Category)(0), // 0: pluggableharness.common.v1.Category - (HookPoint)(0), // 1: pluggableharness.common.v1.HookPoint - (*ProducerRef)(nil), // 2: pluggableharness.common.v1.ProducerRef - (*ProviderRef)(nil), // 3: pluggableharness.common.v1.ProviderRef - (*CallContext)(nil), // 4: pluggableharness.common.v1.CallContext +var file_pluggableharness_common_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_pluggableharness_common_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_pluggableharness_common_v1_types_proto_goTypes = []any{ + (Category)(0), // 0: pluggableharness.common.v1.Category + (HookPoint)(0), // 1: pluggableharness.common.v1.HookPoint + (*ProducerRef)(nil), // 2: pluggableharness.common.v1.ProducerRef + (*ProviderRef)(nil), // 3: pluggableharness.common.v1.ProviderRef + (*CallContext)(nil), // 4: pluggableharness.common.v1.CallContext + (*PromptExpansionSpec)(nil), // 5: pluggableharness.common.v1.PromptExpansionSpec } -var file_pluggableharness_common_v1_common_proto_depIdxs = []int32{ +var file_pluggableharness_common_v1_types_proto_depIdxs = []int32{ 0, // 0: pluggableharness.common.v1.ProducerRef.category:type_name -> pluggableharness.common.v1.Category 0, // 1: pluggableharness.common.v1.ProviderRef.category:type_name -> pluggableharness.common.v1.Category 2, // [2:2] is the sub-list for method output_type @@ -499,27 +591,27 @@ var file_pluggableharness_common_v1_common_proto_depIdxs = []int32{ 0, // [0:2] is the sub-list for field type_name } -func init() { file_pluggableharness_common_v1_common_proto_init() } -func file_pluggableharness_common_v1_common_proto_init() { - if File_pluggableharness_common_v1_common_proto != nil { +func init() { file_pluggableharness_common_v1_types_proto_init() } +func file_pluggableharness_common_v1_types_proto_init() { + if File_pluggableharness_common_v1_types_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_common_v1_common_proto_rawDesc), len(file_pluggableharness_common_v1_common_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_common_v1_types_proto_rawDesc), len(file_pluggableharness_common_v1_types_proto_rawDesc)), NumEnums: 2, - NumMessages: 3, + NumMessages: 4, NumExtensions: 0, NumServices: 0, }, - GoTypes: file_pluggableharness_common_v1_common_proto_goTypes, - DependencyIndexes: file_pluggableharness_common_v1_common_proto_depIdxs, - EnumInfos: file_pluggableharness_common_v1_common_proto_enumTypes, - MessageInfos: file_pluggableharness_common_v1_common_proto_msgTypes, + GoTypes: file_pluggableharness_common_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_common_v1_types_proto_depIdxs, + EnumInfos: file_pluggableharness_common_v1_types_proto_enumTypes, + MessageInfos: file_pluggableharness_common_v1_types_proto_msgTypes, }.Build() - File_pluggableharness_common_v1_common_proto = out.File - file_pluggableharness_common_v1_common_proto_goTypes = nil - file_pluggableharness_common_v1_common_proto_depIdxs = nil + File_pluggableharness_common_v1_types_proto = out.File + file_pluggableharness_common_v1_types_proto_goTypes = nil + file_pluggableharness_common_v1_types_proto_depIdxs = nil } diff --git a/pkg/config/attribute.go b/pkg/config/attribute.go new file mode 100644 index 0000000..42513a2 --- /dev/null +++ b/pkg/config/attribute.go @@ -0,0 +1,103 @@ +package config + +import ( + "fmt" + + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" +) + +// attributeOptions collects AttributeOption values before Attribute +// validates them as a whole. Defaults (all fields at their Go zero value) +// match ConfigAttribute's own wire defaults: not required, not sensitive, +// no description, no nested schema, no default. +type attributeOptions struct { + required bool + sensitive bool + description string + objectAttributes []*configv1.ConfigAttribute + defaultJSON string + hasDefault bool +} + +// AttributeOption configures one optional field of a ConfigAttribute built +// by Attribute. An option only records the caller's intent; Attribute +// validates the fully-assembled attribute once, after every option has run. +type AttributeOption func(*attributeOptions) + +// WithRequired marks the attribute as required: agent.hcl MUST set it, and +// the kernel MUST reject a Configure call that omits it. +func WithRequired() AttributeOption { + return func(o *attributeOptions) { o.required = true } +} + +// WithSensitive marks the attribute as able to hold a secret. A sensitive +// attribute's agent.hcl expression is restricted to env(...) indirection +// (blocks-reference.md#secrets-sensitive-and-env) and, per +// ErrSensitiveDefault, cannot also carry a WithDefault value. +func WithSensitive() AttributeOption { + return func(o *attributeOptions) { o.sensitive = true } +} + +// WithDescription sets the human-readable description shown wherever this +// attribute's schema is surfaced to an operator (docs generation, +// validation errors). +func WithDescription(description string) AttributeOption { + return func(o *attributeOptions) { o.description = description } +} + +// WithDefault sets DefaultJson to the given JSON-encoded text — the value +// the schema-to-cty bridge uses when this attribute is optional and +// agent.hcl omits it. defaultJSON's shape MUST match the attribute's +// declared type (a JSON string for ATTR_TYPE_STRING, a JSON array of +// numbers for ATTR_TYPE_LIST_NUMBER, and so on); Attribute rejects a +// mismatch with ErrDefaultTypeMismatch, and rejects any default at all on a +// sensitive attribute with ErrSensitiveDefault. +func WithDefault(defaultJSON string) AttributeOption { + return func(o *attributeOptions) { + o.defaultJSON = defaultJSON + o.hasDefault = true + } +} + +// WithObjectAttributes sets the nested schema for an ATTR_TYPE_OBJECT +// attribute. It is only legal when Attribute's typ argument is +// ATTR_TYPE_OBJECT; Attribute rejects any other combination with +// ErrObjectAttributesMismatch. Each element is expected to already be a +// validated *configv1.ConfigAttribute — typically one built by a nested +// call to Attribute itself. +func WithObjectAttributes(attrs ...*configv1.ConfigAttribute) AttributeOption { + return func(o *attributeOptions) { o.objectAttributes = attrs } +} + +// Attribute builds one ConfigAttribute and validates it immediately against +// the schema-to-cty bridge's invariants (package doc, blocks-reference.md#the-schema-to-cty-bridge): +// ObjectAttributes set iff typ is ATTR_TYPE_OBJECT, no DefaultJson on a +// sensitive attribute, and DefaultJson's JSON shape matching typ. A caller +// gets back either a known-good *configv1.ConfigAttribute or an error +// identifying which invariant it violated (compare with errors.Is against +// ErrUnspecifiedType, ErrObjectAttributesMismatch, ErrSensitiveDefault, or +// ErrDefaultTypeMismatch). +func Attribute(name string, typ configv1.AttrType, opts ...AttributeOption) (*configv1.ConfigAttribute, error) { + var o attributeOptions + for _, opt := range opts { + opt(&o) + } + + attr := &configv1.ConfigAttribute{ + Name: name, + Type: typ, + Required: o.required, + Sensitive: o.sensitive, + Description: o.description, + ObjectAttributes: o.objectAttributes, + } + if o.hasDefault { + defaultJSON := o.defaultJSON + attr.DefaultJson = &defaultJSON + } + + if err := validateAttribute(attr); err != nil { + return nil, fmt.Errorf("config: attribute: %w", err) + } + return attr, nil +} diff --git a/pkg/config/attribute_test.go b/pkg/config/attribute_test.go new file mode 100644 index 0000000..64476f1 --- /dev/null +++ b/pkg/config/attribute_test.go @@ -0,0 +1,215 @@ +package config_test + +import ( + "errors" + "testing" + + "github.com/pluggableharness/agent/pkg/config" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" +) + +func TestAttribute_Valid(t *testing.T) { + t.Parallel() + + nested, err := config.Attribute("port", configv1.AttrType_ATTR_TYPE_NUMBER, config.WithRequired()) + if err != nil { + t.Fatalf("Attribute(port) = _, %v, want nil error", err) + } + + tests := []struct { + name string + typ configv1.AttrType + opts []config.AttributeOption + }{ + { + name: "string with matching default", + typ: configv1.AttrType_ATTR_TYPE_STRING, + opts: []config.AttributeOption{config.WithDefault(`"us-east-1"`)}, + }, + { + name: "number with matching default", + typ: configv1.AttrType_ATTR_TYPE_NUMBER, + opts: []config.AttributeOption{config.WithDefault("30")}, + }, + { + name: "bool with matching default", + typ: configv1.AttrType_ATTR_TYPE_BOOL, + opts: []config.AttributeOption{config.WithDefault("true")}, + }, + { + name: "list_string with matching default", + typ: configv1.AttrType_ATTR_TYPE_LIST_STRING, + opts: []config.AttributeOption{config.WithDefault(`["CLAUDE.md","**/CLAUDE.md"]`)}, + }, + { + name: "list_number with matching default", + typ: configv1.AttrType_ATTR_TYPE_LIST_NUMBER, + opts: []config.AttributeOption{config.WithDefault("[1,2,3]")}, + }, + { + name: "map_string with matching default", + typ: configv1.AttrType_ATTR_TYPE_MAP_STRING, + opts: []config.AttributeOption{config.WithDefault(`{"a":"b"}`)}, + }, + { + name: "required sensitive without default", + typ: configv1.AttrType_ATTR_TYPE_STRING, + opts: []config.AttributeOption{config.WithRequired(), config.WithSensitive()}, + }, + { + name: "description set", + typ: configv1.AttrType_ATTR_TYPE_STRING, + opts: []config.AttributeOption{config.WithDescription("token budget in characters")}, + }, + { + name: "object with nested attributes", + typ: configv1.AttrType_ATTR_TYPE_OBJECT, + opts: []config.AttributeOption{config.WithObjectAttributes(nested)}, + }, + { + name: "object with matching nested default", + typ: configv1.AttrType_ATTR_TYPE_OBJECT, + opts: []config.AttributeOption{ + config.WithObjectAttributes(nested), + config.WithDefault(`{"port":8080}`), + }, + }, + { + name: "object with partial nested default", + typ: configv1.AttrType_ATTR_TYPE_OBJECT, + opts: []config.AttributeOption{ + config.WithObjectAttributes(nested), + config.WithDefault(`{}`), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := config.Attribute("attr", tt.typ, tt.opts...) + if err != nil { + t.Fatalf("Attribute(%q, %v) = _, %v, want nil error", "attr", tt.typ, err) + } + if got.GetName() != "attr" { + t.Errorf("GetName() = %q, want %q", got.GetName(), "attr") + } + if got.GetType() != tt.typ { + t.Errorf("GetType() = %v, want %v", got.GetType(), tt.typ) + } + }) + } +} + +func TestAttribute_UnspecifiedType(t *testing.T) { + t.Parallel() + + _, err := config.Attribute("attr", configv1.AttrType_ATTR_TYPE_UNSPECIFIED) + if !errors.Is(err, config.ErrUnspecifiedType) { + t.Errorf("Attribute(ATTR_TYPE_UNSPECIFIED) error = %v, want errors.Is ErrUnspecifiedType", err) + } +} + +func TestAttribute_ObjectAttributesMismatch(t *testing.T) { + t.Parallel() + + child, err := config.Attribute("child", configv1.AttrType_ATTR_TYPE_STRING) + if err != nil { + t.Fatalf("Attribute(child) = _, %v, want nil error", err) + } + + tests := []struct { + name string + typ configv1.AttrType + opts []config.AttributeOption + }{ + { + name: "object missing object_attributes", + typ: configv1.AttrType_ATTR_TYPE_OBJECT, + opts: nil, + }, + { + name: "non-object with object_attributes", + typ: configv1.AttrType_ATTR_TYPE_STRING, + opts: []config.AttributeOption{config.WithObjectAttributes(child)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := config.Attribute("attr", tt.typ, tt.opts...) + if !errors.Is(err, config.ErrObjectAttributesMismatch) { + t.Errorf("Attribute(%v) error = %v, want errors.Is ErrObjectAttributesMismatch", tt.typ, err) + } + }) + } +} + +func TestAttribute_SensitiveDefault(t *testing.T) { + t.Parallel() + + _, err := config.Attribute("api_key", configv1.AttrType_ATTR_TYPE_STRING, + config.WithSensitive(), config.WithDefault(`"literal-secret"`)) + if !errors.Is(err, config.ErrSensitiveDefault) { + t.Errorf("Attribute(sensitive+default) error = %v, want errors.Is ErrSensitiveDefault", err) + } +} + +func TestAttribute_DefaultTypeMismatch(t *testing.T) { + t.Parallel() + + nested, err := config.Attribute("port", configv1.AttrType_ATTR_TYPE_NUMBER) + if err != nil { + t.Fatalf("Attribute(port) = _, %v, want nil error", err) + } + + tests := []struct { + name string + typ configv1.AttrType + defaultJSON string + objOpt config.AttributeOption + }{ + {name: "not valid JSON at all", typ: configv1.AttrType_ATTR_TYPE_STRING, defaultJSON: `not-json`}, + {name: "number for string", typ: configv1.AttrType_ATTR_TYPE_STRING, defaultJSON: `5`}, + {name: "string for number", typ: configv1.AttrType_ATTR_TYPE_NUMBER, defaultJSON: `"5"`}, + {name: "string for bool", typ: configv1.AttrType_ATTR_TYPE_BOOL, defaultJSON: `"true"`}, + {name: "list of numbers for list_string", typ: configv1.AttrType_ATTR_TYPE_LIST_STRING, defaultJSON: `[1,2]`}, + {name: "object for list_number", typ: configv1.AttrType_ATTR_TYPE_LIST_NUMBER, defaultJSON: `{}`}, + {name: "map of numbers for map_string", typ: configv1.AttrType_ATTR_TYPE_MAP_STRING, defaultJSON: `{"a":1}`}, + {name: "array for object", typ: configv1.AttrType_ATTR_TYPE_OBJECT, defaultJSON: `[1,2]`, objOpt: config.WithObjectAttributes(nested)}, + {name: "undeclared field in object default", typ: configv1.AttrType_ATTR_TYPE_OBJECT, defaultJSON: `{"bogus":1}`, objOpt: config.WithObjectAttributes(nested)}, + {name: "nested field wrong shape", typ: configv1.AttrType_ATTR_TYPE_OBJECT, defaultJSON: `{"port":"not-a-number"}`, objOpt: config.WithObjectAttributes(nested)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + opts := []config.AttributeOption{config.WithDefault(tt.defaultJSON)} + if tt.objOpt != nil { + opts = append(opts, tt.objOpt) + } + _, err := config.Attribute("attr", tt.typ, opts...) + if !errors.Is(err, config.ErrDefaultTypeMismatch) { + t.Errorf("Attribute(%v, default=%s) error = %v, want errors.Is ErrDefaultTypeMismatch", tt.typ, tt.defaultJSON, err) + } + }) + } +} + +func TestAttribute_UnrecognizedTypeWithDefault(t *testing.T) { + t.Parallel() + + // An out-of-range AttrType value that is nonetheless not the + // zero/unspecified value exercises validateDefaultShape's default + // branch directly, without object_attributes ever entering the picture. + const bogus = configv1.AttrType(99) + + _, err := config.Attribute("attr", bogus, config.WithDefault(`1`)) + if !errors.Is(err, config.ErrDefaultTypeMismatch) { + t.Errorf("Attribute(bogus type, default) error = %v, want errors.Is ErrDefaultTypeMismatch", err) + } +} diff --git a/pkg/config/doc.go b/pkg/config/doc.go new file mode 100644 index 0000000..ab3d57e --- /dev/null +++ b/pkg/config/doc.go @@ -0,0 +1,34 @@ +// Package config builds validated pluggableharness.config.v1.ConfigSchema +// and ConfigAttribute values for a plugin's GetCapabilities/GetSchema +// response — the schema the kernel decodes a matching agent.hcl provider +// block against before ever calling that provider's Configure RPC (see +// docs/specifications/configuration/blocks-reference.md#the-schema-to-cty-bridge). +// +// The generated pkg/config/proto/v1 types are a flat struct literal: nothing +// stops a plugin author from hand-assembling a ConfigAttribute that silently +// violates one of the invariants blocks-reference.md's schema-to-cty bridge +// section requires. This package exists so a caller cannot easily construct +// an invalid one. It enforces three rules, each drawn directly from that +// section: +// +// - ObjectAttributes MUST be non-empty if and only if Type is +// AttrType_ATTR_TYPE_OBJECT; it MUST be empty for every other type +// (blocks-reference.md#the-schema-to-cty-bridge, "Nested object +// attributes"). +// - DefaultJson MUST NOT be set on an attribute with Sensitive == true — a +// declared default is a literal value baked into the schema +// advertisement itself, which is exactly what the secrets rule forbids +// regardless of where the literal appears (blocks-reference.md#the-schema-to-cty-bridge, +// "Declared defaults"). +// - DefaultJson's JSON shape MUST match the attribute's declared Type, +// recursively for a nested ATTR_TYPE_OBJECT default +// (blocks-reference.md#the-schema-to-cty-bridge, "Declared defaults"). +// +// Attribute is the primary entry point: it validates a single attribute +// immediately, so a caller gets a *configv1.ConfigAttribute that is already +// known-good or an error explaining which invariant it violated. Schema +// additionally re-validates the whole attribute tree — including any +// ObjectAttributes supplied as hand-built struct literals rather than +// through Attribute — before assembling a *configv1.ConfigSchema, so an +// invalid attribute cannot reach the wire regardless of how it was built. +package config diff --git a/pkg/config/errors.go b/pkg/config/errors.go new file mode 100644 index 0000000..64e5e2b --- /dev/null +++ b/pkg/config/errors.go @@ -0,0 +1,35 @@ +package config + +import "errors" + +// ErrUnspecifiedType is returned when an attribute's Type is left at its +// zero value, AttrType_ATTR_TYPE_UNSPECIFIED. The generated type's own doc +// comment states this value is "never valid for a real attribute; its +// presence on the wire means a caller forgot to set the field" — this +// package rejects it at construction time rather than letting it reach the +// kernel's schema-to-cty bridge. +var ErrUnspecifiedType = errors.New("config: type must not be ATTR_TYPE_UNSPECIFIED") + +// ErrObjectAttributesMismatch is returned when ObjectAttributes is empty on +// an ATTR_TYPE_OBJECT attribute, or non-empty on any other type. See +// docs/specifications/configuration/blocks-reference.md#the-schema-to-cty-bridge, +// "Nested object attributes": ObjectAttributes "MUST be set (non-empty) iff +// type == object; MUST be empty for every other type." +var ErrObjectAttributesMismatch = errors.New("config: object_attributes must be set iff type is ATTR_TYPE_OBJECT") + +// ErrSensitiveDefault is returned when both DefaultJson and Sensitive are +// set on the same attribute. See +// docs/specifications/configuration/blocks-reference.md#the-schema-to-cty-bridge, +// "Declared defaults": "default_json MUST NOT be set on an attribute with +// sensitive = true — a declared default is a literal value baked into the +// schema advertisement itself, which is exactly the literal-secret-value +// case 'Secrets' below forbids regardless of where the literal appears." +var ErrSensitiveDefault = errors.New("config: default_json must not be set when sensitive is true") + +// ErrDefaultTypeMismatch is returned when DefaultJson does not parse as +// JSON, or parses but its shape does not match the attribute's declared +// Type. See docs/specifications/configuration/blocks-reference.md#the-schema-to-cty-bridge, +// "Declared defaults": "A default_json that doesn't parse as JSON, or that +// parses but doesn't match type's expected shape, MUST be rejected as a +// config-load-time error." +var ErrDefaultTypeMismatch = errors.New("config: default_json does not match the attribute's declared type") diff --git a/pkg/config/proto/v1/config.pb.go b/pkg/config/proto/v1/types.pb.go similarity index 80% rename from pkg/config/proto/v1/config.pb.go rename to pkg/config/proto/v1/types.pb.go index 2040b78..d491da5 100644 --- a/pkg/config/proto/v1/config.pb.go +++ b/pkg/config/proto/v1/types.pb.go @@ -2,7 +2,7 @@ // versions: // protoc-gen-go v1.36.11 // protoc (unknown) -// source: pluggableharness/config/v1/config.proto +// source: pluggableharness/config/v1/types.proto // Package pluggableharness.config.v1 defines the on-the-wire config-schema // advertisement described in specifications/configuration.md §4. This is @@ -83,11 +83,11 @@ func (x AttrType) String() string { } func (AttrType) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_config_v1_config_proto_enumTypes[0].Descriptor() + return file_pluggableharness_config_v1_types_proto_enumTypes[0].Descriptor() } func (AttrType) Type() protoreflect.EnumType { - return &file_pluggableharness_config_v1_config_proto_enumTypes[0] + return &file_pluggableharness_config_v1_types_proto_enumTypes[0] } func (x AttrType) Number() protoreflect.EnumNumber { @@ -96,7 +96,7 @@ func (x AttrType) Number() protoreflect.EnumNumber { // Deprecated: Use AttrType.Descriptor instead. func (AttrType) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_config_v1_config_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_config_v1_types_proto_rawDescGZIP(), []int{0} } // ConfigAttribute declares one field a provider's agent.hcl config block @@ -150,7 +150,7 @@ type ConfigAttribute struct { func (x *ConfigAttribute) Reset() { *x = ConfigAttribute{} - mi := &file_pluggableharness_config_v1_config_proto_msgTypes[0] + mi := &file_pluggableharness_config_v1_types_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -162,7 +162,7 @@ func (x *ConfigAttribute) String() string { func (*ConfigAttribute) ProtoMessage() {} func (x *ConfigAttribute) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_config_v1_config_proto_msgTypes[0] + mi := &file_pluggableharness_config_v1_types_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -175,7 +175,7 @@ func (x *ConfigAttribute) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigAttribute.ProtoReflect.Descriptor instead. func (*ConfigAttribute) Descriptor() ([]byte, []int) { - return file_pluggableharness_config_v1_config_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_config_v1_types_proto_rawDescGZIP(), []int{0} } func (x *ConfigAttribute) GetName() string { @@ -239,7 +239,7 @@ type ConfigSchema struct { func (x *ConfigSchema) Reset() { *x = ConfigSchema{} - mi := &file_pluggableharness_config_v1_config_proto_msgTypes[1] + mi := &file_pluggableharness_config_v1_types_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -251,7 +251,7 @@ func (x *ConfigSchema) String() string { func (*ConfigSchema) ProtoMessage() {} func (x *ConfigSchema) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_config_v1_config_proto_msgTypes[1] + mi := &file_pluggableharness_config_v1_types_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -264,7 +264,7 @@ func (x *ConfigSchema) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigSchema.ProtoReflect.Descriptor instead. func (*ConfigSchema) Descriptor() ([]byte, []int) { - return file_pluggableharness_config_v1_config_proto_rawDescGZIP(), []int{1} + return file_pluggableharness_config_v1_types_proto_rawDescGZIP(), []int{1} } func (x *ConfigSchema) GetAttributes() []*ConfigAttribute { @@ -274,11 +274,11 @@ func (x *ConfigSchema) GetAttributes() []*ConfigAttribute { return nil } -var File_pluggableharness_config_v1_config_proto protoreflect.FileDescriptor +var File_pluggableharness_config_v1_types_proto protoreflect.FileDescriptor -const file_pluggableharness_config_v1_config_proto_rawDesc = "" + +const file_pluggableharness_config_v1_types_proto_rawDesc = "" + "\n" + - "'pluggableharness/config/v1/config.proto\x12\x1apluggableharness.config.v1\"\xce\x02\n" + + "&pluggableharness/config/v1/types.proto\x12\x1apluggableharness.config.v1\"\xce\x02\n" + "\x0fConfigAttribute\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x128\n" + "\x04type\x18\x02 \x01(\x0e2$.pluggableharness.config.v1.AttrTypeR\x04type\x12\x1a\n" + @@ -303,25 +303,25 @@ const file_pluggableharness_config_v1_config_proto_rawDesc = "" + "\x10ATTR_TYPE_OBJECT\x10\aB@Z>github.com/pluggableharness/agent/pkg/config/proto/v1;configv1b\x06proto3" var ( - file_pluggableharness_config_v1_config_proto_rawDescOnce sync.Once - file_pluggableharness_config_v1_config_proto_rawDescData []byte + file_pluggableharness_config_v1_types_proto_rawDescOnce sync.Once + file_pluggableharness_config_v1_types_proto_rawDescData []byte ) -func file_pluggableharness_config_v1_config_proto_rawDescGZIP() []byte { - file_pluggableharness_config_v1_config_proto_rawDescOnce.Do(func() { - file_pluggableharness_config_v1_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_config_v1_config_proto_rawDesc), len(file_pluggableharness_config_v1_config_proto_rawDesc))) +func file_pluggableharness_config_v1_types_proto_rawDescGZIP() []byte { + file_pluggableharness_config_v1_types_proto_rawDescOnce.Do(func() { + file_pluggableharness_config_v1_types_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_config_v1_types_proto_rawDesc), len(file_pluggableharness_config_v1_types_proto_rawDesc))) }) - return file_pluggableharness_config_v1_config_proto_rawDescData + return file_pluggableharness_config_v1_types_proto_rawDescData } -var file_pluggableharness_config_v1_config_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_pluggableharness_config_v1_config_proto_msgTypes = make([]protoimpl.MessageInfo, 2) -var file_pluggableharness_config_v1_config_proto_goTypes = []any{ +var file_pluggableharness_config_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_pluggableharness_config_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_pluggableharness_config_v1_types_proto_goTypes = []any{ (AttrType)(0), // 0: pluggableharness.config.v1.AttrType (*ConfigAttribute)(nil), // 1: pluggableharness.config.v1.ConfigAttribute (*ConfigSchema)(nil), // 2: pluggableharness.config.v1.ConfigSchema } -var file_pluggableharness_config_v1_config_proto_depIdxs = []int32{ +var file_pluggableharness_config_v1_types_proto_depIdxs = []int32{ 0, // 0: pluggableharness.config.v1.ConfigAttribute.type:type_name -> pluggableharness.config.v1.AttrType 1, // 1: pluggableharness.config.v1.ConfigAttribute.object_attributes:type_name -> pluggableharness.config.v1.ConfigAttribute 1, // 2: pluggableharness.config.v1.ConfigSchema.attributes:type_name -> pluggableharness.config.v1.ConfigAttribute @@ -332,28 +332,28 @@ var file_pluggableharness_config_v1_config_proto_depIdxs = []int32{ 0, // [0:3] is the sub-list for field type_name } -func init() { file_pluggableharness_config_v1_config_proto_init() } -func file_pluggableharness_config_v1_config_proto_init() { - if File_pluggableharness_config_v1_config_proto != nil { +func init() { file_pluggableharness_config_v1_types_proto_init() } +func file_pluggableharness_config_v1_types_proto_init() { + if File_pluggableharness_config_v1_types_proto != nil { return } - file_pluggableharness_config_v1_config_proto_msgTypes[0].OneofWrappers = []any{} + file_pluggableharness_config_v1_types_proto_msgTypes[0].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_config_v1_config_proto_rawDesc), len(file_pluggableharness_config_v1_config_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_config_v1_types_proto_rawDesc), len(file_pluggableharness_config_v1_types_proto_rawDesc)), NumEnums: 1, NumMessages: 2, NumExtensions: 0, NumServices: 0, }, - GoTypes: file_pluggableharness_config_v1_config_proto_goTypes, - DependencyIndexes: file_pluggableharness_config_v1_config_proto_depIdxs, - EnumInfos: file_pluggableharness_config_v1_config_proto_enumTypes, - MessageInfos: file_pluggableharness_config_v1_config_proto_msgTypes, + GoTypes: file_pluggableharness_config_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_config_v1_types_proto_depIdxs, + EnumInfos: file_pluggableharness_config_v1_types_proto_enumTypes, + MessageInfos: file_pluggableharness_config_v1_types_proto_msgTypes, }.Build() - File_pluggableharness_config_v1_config_proto = out.File - file_pluggableharness_config_v1_config_proto_goTypes = nil - file_pluggableharness_config_v1_config_proto_depIdxs = nil + File_pluggableharness_config_v1_types_proto = out.File + file_pluggableharness_config_v1_types_proto_goTypes = nil + file_pluggableharness_config_v1_types_proto_depIdxs = nil } diff --git a/pkg/config/schema.go b/pkg/config/schema.go new file mode 100644 index 0000000..c941040 --- /dev/null +++ b/pkg/config/schema.go @@ -0,0 +1,25 @@ +package config + +import ( + "fmt" + + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" +) + +// Schema assembles a ConfigSchema from attrs, validating every attribute in +// the tree — including nested ObjectAttributes — before returning it. An +// attribute built through Attribute is already known-good, so this pass is +// normally a no-op; it exists so Schema itself, not just Attribute, is the +// place a caller can trust to catch a hand-built *configv1.ConfigAttribute +// struct literal that bypassed Attribute (see doc.go). It returns an error +// identifying the first violated invariant, checked with errors.Is against +// ErrUnspecifiedType, ErrObjectAttributesMismatch, ErrSensitiveDefault, or +// ErrDefaultTypeMismatch. +func Schema(attrs ...*configv1.ConfigAttribute) (*configv1.ConfigSchema, error) { + for _, attr := range attrs { + if err := validateAttribute(attr); err != nil { + return nil, fmt.Errorf("config: schema: %w", err) + } + } + return &configv1.ConfigSchema{Attributes: attrs}, nil +} diff --git a/pkg/config/schema_test.go b/pkg/config/schema_test.go new file mode 100644 index 0000000..3f3b0f0 --- /dev/null +++ b/pkg/config/schema_test.go @@ -0,0 +1,113 @@ +package config_test + +import ( + "errors" + "testing" + + "github.com/pluggableharness/agent/pkg/config" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" +) + +func TestSchema_Valid(t *testing.T) { + t.Parallel() + + region, err := config.Attribute("region", configv1.AttrType_ATTR_TYPE_STRING, config.WithRequired()) + if err != nil { + t.Fatalf("Attribute(region) = _, %v, want nil error", err) + } + apiKey, err := config.Attribute("api_key", configv1.AttrType_ATTR_TYPE_STRING, config.WithSensitive()) + if err != nil { + t.Fatalf("Attribute(api_key) = _, %v, want nil error", err) + } + + got, err := config.Schema(region, apiKey) + if err != nil { + t.Fatalf("Schema(region, api_key) = _, %v, want nil error", err) + } + if len(got.GetAttributes()) != 2 { + t.Fatalf("Schema(...).GetAttributes() has %d entries, want 2", len(got.GetAttributes())) + } + if got.GetAttributes()[0].GetName() != "region" || got.GetAttributes()[1].GetName() != "api_key" { + t.Errorf("Schema(...).GetAttributes() = %v, want [region api_key] order preserved", got.GetAttributes()) + } +} + +func TestSchema_Empty(t *testing.T) { + t.Parallel() + + got, err := config.Schema() + if err != nil { + t.Fatalf("Schema() = _, %v, want nil error", err) + } + if len(got.GetAttributes()) != 0 { + t.Errorf("Schema().GetAttributes() has %d entries, want 0", len(got.GetAttributes())) + } +} + +// TestSchema_CatchesHandBuiltAttribute proves Schema re-validates its whole +// tree rather than trusting that every *configv1.ConfigAttribute it +// receives came from Attribute — see doc.go and validate.go's +// validateAttribute doc comment. +func TestSchema_CatchesHandBuiltAttribute(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + attr *configv1.ConfigAttribute + want error + }{ + { + name: "unspecified type", + attr: &configv1.ConfigAttribute{Name: "attr"}, + want: config.ErrUnspecifiedType, + }, + { + name: "object missing object_attributes", + attr: &configv1.ConfigAttribute{Name: "attr", Type: configv1.AttrType_ATTR_TYPE_OBJECT}, + want: config.ErrObjectAttributesMismatch, + }, + { + name: "sensitive with default", + attr: &configv1.ConfigAttribute{ + Name: "attr", + Type: configv1.AttrType_ATTR_TYPE_STRING, + Sensitive: true, + DefaultJson: strPtr(`"literal"`), + }, + want: config.ErrSensitiveDefault, + }, + { + name: "default shape mismatch", + attr: &configv1.ConfigAttribute{ + Name: "attr", + Type: configv1.AttrType_ATTR_TYPE_NUMBER, + DefaultJson: strPtr(`"not-a-number"`), + }, + want: config.ErrDefaultTypeMismatch, + }, + { + name: "invalid attribute nested inside a valid object", + attr: &configv1.ConfigAttribute{ + Name: "attr", + Type: configv1.AttrType_ATTR_TYPE_OBJECT, + ObjectAttributes: []*configv1.ConfigAttribute{ + {Name: "child"}, // ATTR_TYPE_UNSPECIFIED, hand-built, never went through Attribute + }, + }, + want: config.ErrUnspecifiedType, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := config.Schema(tt.attr) + if !errors.Is(err, tt.want) { + t.Errorf("Schema(%+v) error = %v, want errors.Is %v", tt.attr, err, tt.want) + } + }) + } +} + +func strPtr(s string) *string { return &s } diff --git a/pkg/config/validate.go b/pkg/config/validate.go new file mode 100644 index 0000000..30c83fd --- /dev/null +++ b/pkg/config/validate.go @@ -0,0 +1,133 @@ +package config + +import ( + "encoding/json" + "fmt" + "sort" + + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" +) + +// validateAttribute checks one already-assembled ConfigAttribute against +// every invariant this package enforces, then recurses into +// ObjectAttributes so a nested attribute — however it was built — gets the +// same treatment as a top-level one (blocks-reference.md#the-schema-to-cty-bridge, +// "Nested object attributes": "each nested ConfigAttribute gets the same +// required/sensitive/description treatment... rather than accepting an +// unvalidated dynamic object"). It is the single source of truth Attribute +// and Schema both call, so a hand-built struct literal that reaches Schema +// without going through Attribute is still caught. +func validateAttribute(attr *configv1.ConfigAttribute) error { + name := attr.GetName() + + if attr.GetType() == configv1.AttrType_ATTR_TYPE_UNSPECIFIED { + return fmt.Errorf("attribute %q: %w", name, ErrUnspecifiedType) + } + + isObject := attr.GetType() == configv1.AttrType_ATTR_TYPE_OBJECT + hasObjectAttributes := len(attr.GetObjectAttributes()) > 0 + if isObject != hasObjectAttributes { + return fmt.Errorf("attribute %q: %w", name, ErrObjectAttributesMismatch) + } + + if attr.DefaultJson != nil { + if attr.GetSensitive() { + return fmt.Errorf("attribute %q: %w", name, ErrSensitiveDefault) + } + if err := validateDefaultShape(attr.GetDefaultJson(), attr.GetType(), attr.GetObjectAttributes()); err != nil { + return fmt.Errorf("attribute %q: %w", name, err) + } + } + + for _, child := range attr.GetObjectAttributes() { + if err := validateAttribute(child); err != nil { + return err + } + } + return nil +} + +// validateDefaultShape checks that raw is valid JSON whose shape matches +// typ, per blocks-reference.md#the-schema-to-cty-bridge's "Declared +// defaults" encoding rule: a JSON string for ATTR_TYPE_STRING, a JSON +// number for ATTR_TYPE_NUMBER, a JSON array of strings/numbers for the two +// list types, a JSON object of string values for ATTR_TYPE_MAP_STRING, and +// a JSON object matching objectAttrs' shape — recursively — for +// ATTR_TYPE_OBJECT. +func validateDefaultShape(raw string, typ configv1.AttrType, objectAttrs []*configv1.ConfigAttribute) error { + switch typ { + case configv1.AttrType_ATTR_TYPE_STRING: + var v string + return decodeDefaultShape(raw, &v) + case configv1.AttrType_ATTR_TYPE_NUMBER: + var v float64 + return decodeDefaultShape(raw, &v) + case configv1.AttrType_ATTR_TYPE_BOOL: + var v bool + return decodeDefaultShape(raw, &v) + case configv1.AttrType_ATTR_TYPE_LIST_STRING: + var v []string + return decodeDefaultShape(raw, &v) + case configv1.AttrType_ATTR_TYPE_LIST_NUMBER: + var v []float64 + return decodeDefaultShape(raw, &v) + case configv1.AttrType_ATTR_TYPE_MAP_STRING: + var v map[string]string + return decodeDefaultShape(raw, &v) + case configv1.AttrType_ATTR_TYPE_OBJECT: + return validateObjectDefaultShape(raw, objectAttrs) + default: + // AttrType_ATTR_TYPE_UNSPECIFIED and anything the wire type adds + // later that this package doesn't yet know how to shape-check. + return fmt.Errorf("%w: unrecognized attribute type %s", ErrDefaultTypeMismatch, typ) + } +} + +// decodeDefaultShape unmarshals raw into dst, reporting any failure — +// syntax error or a JSON value of the wrong Go-mapped kind — as +// ErrDefaultTypeMismatch. +func decodeDefaultShape(raw string, dst any) error { + if err := json.Unmarshal([]byte(raw), dst); err != nil { + return fmt.Errorf("%w: %w", ErrDefaultTypeMismatch, err) + } + return nil +} + +// validateObjectDefaultShape checks that raw is a JSON object and that +// each field present in it matches the corresponding declared +// objectAttrs entry's type, recursively. A field objectAttrs declares but +// raw omits is fine — the default need not populate every nested field, +// exactly as an optional top-level attribute need not appear in +// agent.hcl at all. +func validateObjectDefaultShape(raw string, objectAttrs []*configv1.ConfigAttribute) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &fields); err != nil { + return fmt.Errorf("%w: %w", ErrDefaultTypeMismatch, err) + } + + byName := make(map[string]*configv1.ConfigAttribute, len(objectAttrs)) + for _, oa := range objectAttrs { + byName[oa.GetName()] = oa + } + + // Sorted iteration keeps the returned error deterministic when more + // than one field is invalid — required by determinism.md whenever map + // contents feed observable output, and it makes this function's own + // behavior reproducible regardless of Go's randomized map order. + fieldNames := make([]string, 0, len(fields)) + for fieldName := range fields { + fieldNames = append(fieldNames, fieldName) + } + sort.Strings(fieldNames) + + for _, fieldName := range fieldNames { + oa, ok := byName[fieldName] + if !ok { + return fmt.Errorf("%w: field %q is not declared in object_attributes", ErrDefaultTypeMismatch, fieldName) + } + if err := validateDefaultShape(string(fields[fieldName]), oa.GetType(), oa.GetObjectAttributes()); err != nil { + return fmt.Errorf("field %q: %w", fieldName, err) + } + } + return nil +} diff --git a/pkg/content/blocks.go b/pkg/content/blocks.go new file mode 100644 index 0000000..810c2b4 --- /dev/null +++ b/pkg/content/blocks.go @@ -0,0 +1,144 @@ +package content + +import ( + "google.golang.org/protobuf/types/known/structpb" + + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" +) + +// Text builds a ContentBlock carrying a TextBlock — the one variant every +// plugin MUST support, in both directions, unconditionally +// (docs/specifications/model/data-types.md's canonical-schema note). +func Text(text string) *contentv1.ContentBlock { + return &contentv1.ContentBlock{ + Block: &contentv1.ContentBlock_Text{ + Text: &contentv1.TextBlock{Text: text}, + }, + } +} + +// Image builds a ContentBlock carrying inline image bytes. data is the +// raw image content and mediaType is its MIME type (e.g. "image/png"). +// Requires the target model's ModelSpec.supports_vision — the kernel MUST +// reject an image block sent to a model where that flag is false +// (docs/specifications/model/data-types.md's canonical-schema note); this +// package does not itself validate that, since the check needs the +// resolved ModelSpec this package has no access to. +func Image(data []byte, mediaType string) *contentv1.ContentBlock { + return &contentv1.ContentBlock{ + Block: &contentv1.ContentBlock_Image{ + Image: &contentv1.ImageBlock{ + Data: data, + MediaType: mediaType, + }, + }, + } +} + +// Document builds a ContentBlock carrying inline non-image document +// content (e.g. a PDF) — the document-attachment analog of Image. data is +// the raw document content and mediaType is its MIME type (e.g. +// "application/pdf"). WithFilename sets the optional original filename. +// Requires the target model's ModelSpec.supports_documents, mirroring +// Image's supports_vision rule +// (docs/specifications/model/data-types.md's canonical-schema note). +func Document(data []byte, mediaType string, opts ...Option) *contentv1.ContentBlock { + var o documentOptions + for _, opt := range opts { + opt(&o) + } + return &contentv1.ContentBlock{ + Block: &contentv1.ContentBlock_Document{ + Document: &contentv1.DocumentBlock{ + Data: data, + MediaType: mediaType, + Filename: o.filename, + }, + }, + } +} + +// ToolUse builds a ContentBlock carrying a model's request to invoke a +// tool. id correlates this block to the ToolResultBlock that answers it +// (matching ToolResult's/ToolErrorResult's toolUseID argument); name is +// the tool's declared name; arguments are the call's already-parsed +// arguments, conforming to the tool's input_schema. Requires +// ModelSpec.supports_tool_use. +func ToolUse(id, name string, arguments *structpb.Struct) *contentv1.ContentBlock { + return &contentv1.ContentBlock{ + Block: &contentv1.ContentBlock_ToolUse{ + ToolUse: &contentv1.ToolUseBlock{ + Id: id, + Name: name, + Arguments: arguments, + }, + }, + } +} + +// ToolResult builds a ContentBlock carrying a successful tool invocation +// outcome. toolUseID is the id of the ToolUseBlock this result answers; +// content is the result's own content, recursively modeled as +// ContentBlocks (not a single string) because some vendors' tool results +// may include non-text content, e.g. an image a tool produced — in +// practice this is almost always a single Text block. Requires +// ModelSpec.supports_tool_use. +func ToolResult(toolUseID string, content ...*contentv1.ContentBlock) *contentv1.ContentBlock { + return toolResult(toolUseID, false, content) +} + +// ToolErrorResult builds a ContentBlock carrying a failed or denied tool +// invocation outcome — e.g. the plan/apply gate's synthesized denial +// block, which MUST let the model observe a denial in its own history +// (docs/specifications/model/data-types.md's ToolResultBlock.is_error +// note). Otherwise identical to ToolResult. +func ToolErrorResult(toolUseID string, content ...*contentv1.ContentBlock) *contentv1.ContentBlock { + return toolResult(toolUseID, true, content) +} + +// toolResult is the shared constructor behind ToolResult and +// ToolErrorResult, differing only in ToolResultBlock.IsError. +func toolResult(toolUseID string, isError bool, content []*contentv1.ContentBlock) *contentv1.ContentBlock { + return &contentv1.ContentBlock{ + Block: &contentv1.ContentBlock_ToolResult{ + ToolResult: &contentv1.ToolResultBlock{ + ToolUseId: toolUseID, + Content: content, + IsError: isError, + }, + }, + } +} + +// Thinking builds a ContentBlock carrying a model's extended-reasoning +// output. text is the accumulated reasoning text; signature is an opaque +// vendor integrity token, when the vendor's thinking blocks carry one — +// it MUST be stored and echoed back verbatim, never inspected or +// reformatted (docs/specifications/model/data-types.md's canonical-schema +// note); pass nil when the vendor doesn't supply one. Only relevant where +// ThinkingSpec.supported. +func Thinking(text string, signature []byte) *contentv1.ContentBlock { + return &contentv1.ContentBlock{ + Block: &contentv1.ContentBlock_Thinking{ + Thinking: &contentv1.ThinkingBlock{ + Text: text, + Signature: signature, + }, + }, + } +} + +// RedactedThinking builds a ContentBlock carrying a vendor-encrypted +// reasoning block that MUST be stored and round-tripped verbatim, exactly +// like Thinking's signature — the kernel never inspects data at all, not +// even as text (docs/specifications/model/data-types.md's canonical-schema +// note). Only relevant where ThinkingSpec.supported. +func RedactedThinking(data []byte) *contentv1.ContentBlock { + return &contentv1.ContentBlock{ + Block: &contentv1.ContentBlock_RedactedThinking{ + RedactedThinking: &contentv1.RedactedThinkingBlock{ + Data: data, + }, + }, + } +} diff --git a/pkg/content/blocks_test.go b/pkg/content/blocks_test.go new file mode 100644 index 0000000..75a6bfd --- /dev/null +++ b/pkg/content/blocks_test.go @@ -0,0 +1,227 @@ +package content_test + +import ( + "testing" + + "google.golang.org/protobuf/types/known/structpb" + + "github.com/pluggableharness/agent/pkg/content" + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" +) + +func TestText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + text string + }{ + {name: "non-empty", text: "hello world"}, + {name: "empty", text: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := content.Text(tt.text) + tb := got.GetText() + if tb == nil { + t.Fatalf("Text(%q).GetText() = nil, want a TextBlock", tt.text) + } + if tb.GetText() != tt.text { + t.Errorf("Text(%q).GetText().GetText() = %q, want %q", tt.text, tb.GetText(), tt.text) + } + if _, ok := got.GetBlock().(*contentv1.ContentBlock_Text); !ok { + t.Errorf("Text(%q) block variant = %T, want *ContentBlock_Text", tt.text, got.GetBlock()) + } + }) + } +} + +func TestImage(t *testing.T) { + t.Parallel() + + data := []byte{0x89, 0x50, 0x4e, 0x47} + const mediaType = "image/png" + + got := content.Image(data, mediaType) + ib := got.GetImage() + if ib == nil { + t.Fatalf("Image().GetImage() = nil, want an ImageBlock") + } + if string(ib.GetData()) != string(data) { + t.Errorf("Image().GetImage().GetData() = %v, want %v", ib.GetData(), data) + } + if ib.GetMediaType() != mediaType { + t.Errorf("Image().GetImage().GetMediaType() = %q, want %q", ib.GetMediaType(), mediaType) + } +} + +func TestDocument(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts []content.Option + wantFilename string + wantHasName bool + }{ + {name: "no filename", opts: nil, wantHasName: false}, + {name: "with filename", opts: []content.Option{content.WithFilename("report.pdf")}, wantFilename: "report.pdf", wantHasName: true}, + } + + data := []byte("%PDF-1.4") + const mediaType = "application/pdf" + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := content.Document(data, mediaType, tt.opts...) + db := got.GetDocument() + if db == nil { + t.Fatalf("Document().GetDocument() = nil, want a DocumentBlock") + } + if string(db.GetData()) != string(data) { + t.Errorf("Document().GetDocument().GetData() = %v, want %v", db.GetData(), data) + } + if db.GetMediaType() != mediaType { + t.Errorf("Document().GetDocument().GetMediaType() = %q, want %q", db.GetMediaType(), mediaType) + } + if hasName := db.Filename != nil; hasName != tt.wantHasName { + t.Errorf("Document() Filename set = %v, want %v", hasName, tt.wantHasName) + } + if tt.wantHasName && db.GetFilename() != tt.wantFilename { + t.Errorf("Document().GetDocument().GetFilename() = %q, want %q", db.GetFilename(), tt.wantFilename) + } + }) + } +} + +func TestToolUse(t *testing.T) { + t.Parallel() + + args, err := structpb.NewStruct(map[string]any{"path": "/tmp/x"}) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + + got := content.ToolUse("tu-1", "read_file", args) + tu := got.GetToolUse() + if tu == nil { + t.Fatalf("ToolUse().GetToolUse() = nil, want a ToolUseBlock") + } + if tu.GetId() != "tu-1" { + t.Errorf("ToolUse().GetToolUse().GetId() = %q, want %q", tu.GetId(), "tu-1") + } + if tu.GetName() != "read_file" { + t.Errorf("ToolUse().GetToolUse().GetName() = %q, want %q", tu.GetName(), "read_file") + } + if tu.GetArguments().GetFields()["path"].GetStringValue() != "/tmp/x" { + t.Errorf("ToolUse().GetToolUse().GetArguments()[path] = %v, want /tmp/x", tu.GetArguments().GetFields()["path"]) + } +} + +func TestToolResult(t *testing.T) { + t.Parallel() + + nested := content.ToolUse("nested-1", "inner_tool", nil) + textBlock := content.Text("done") + + got := content.ToolResult("tu-1", textBlock, nested) + tr := got.GetToolResult() + if tr == nil { + t.Fatalf("ToolResult().GetToolResult() = nil, want a ToolResultBlock") + } + if tr.GetToolUseId() != "tu-1" { + t.Errorf("ToolResult().GetToolResult().GetToolUseId() = %q, want %q", tr.GetToolUseId(), "tu-1") + } + if tr.GetIsError() { + t.Error("ToolResult().GetToolResult().GetIsError() = true, want false") + } + nestedContent := tr.GetContent() + if len(nestedContent) != 2 { + t.Fatalf("ToolResult() content length = %d, want 2", len(nestedContent)) + } + if nestedContent[0].GetText().GetText() != "done" { + t.Errorf("ToolResult() content[0] text = %q, want %q", nestedContent[0].GetText().GetText(), "done") + } + if nestedContent[1].GetToolUse().GetName() != "inner_tool" { + t.Errorf("ToolResult() content[1] tool_use name = %q, want %q", nestedContent[1].GetToolUse().GetName(), "inner_tool") + } +} + +func TestToolErrorResult(t *testing.T) { + t.Parallel() + + got := content.ToolErrorResult("tu-2", content.Text("denied")) + tr := got.GetToolResult() + if tr == nil { + t.Fatalf("ToolErrorResult().GetToolResult() = nil, want a ToolResultBlock") + } + if !tr.GetIsError() { + t.Error("ToolErrorResult().GetToolResult().GetIsError() = false, want true") + } + if tr.GetToolUseId() != "tu-2" { + t.Errorf("ToolErrorResult().GetToolResult().GetToolUseId() = %q, want %q", tr.GetToolUseId(), "tu-2") + } + if len(tr.GetContent()) != 1 || tr.GetContent()[0].GetText().GetText() != "denied" { + t.Errorf("ToolErrorResult() content = %v, want single text block %q", tr.GetContent(), "denied") + } +} + +func TestToolResult_noContent(t *testing.T) { + t.Parallel() + + got := content.ToolResult("tu-3") + if got.GetToolResult() == nil { + t.Fatalf("ToolResult(no content).GetToolResult() = nil, want a ToolResultBlock") + } + if len(got.GetToolResult().GetContent()) != 0 { + t.Errorf("ToolResult(no content) content = %v, want empty", got.GetToolResult().GetContent()) + } +} + +func TestThinking(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + text string + signature []byte + }{ + {name: "with signature", text: "let me reason about this", signature: []byte{0x01, 0x02}}, + {name: "nil signature", text: "reasoning", signature: nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := content.Thinking(tt.text, tt.signature) + tb := got.GetThinking() + if tb == nil { + t.Fatalf("Thinking().GetThinking() = nil, want a ThinkingBlock") + } + if tb.GetText() != tt.text { + t.Errorf("Thinking().GetThinking().GetText() = %q, want %q", tb.GetText(), tt.text) + } + if string(tb.GetSignature()) != string(tt.signature) { + t.Errorf("Thinking().GetThinking().GetSignature() = %v, want %v", tb.GetSignature(), tt.signature) + } + }) + } +} + +func TestRedactedThinking(t *testing.T) { + t.Parallel() + + data := []byte{0xde, 0xad, 0xbe, 0xef} + got := content.RedactedThinking(data) + rb := got.GetRedactedThinking() + if rb == nil { + t.Fatalf("RedactedThinking().GetRedactedThinking() = nil, want a RedactedThinkingBlock") + } + if string(rb.GetData()) != string(data) { + t.Errorf("RedactedThinking().GetRedactedThinking().GetData() = %v, want %v", rb.GetData(), data) + } +} diff --git a/pkg/content/doc.go b/pkg/content/doc.go new file mode 100644 index 0000000..2f1ef14 --- /dev/null +++ b/pkg/content/doc.go @@ -0,0 +1,27 @@ +// Package content provides the hand-written, ergonomic builder layer over +// the generated pluggableharness.content.v1 types in ./proto/v1 — the +// canonical content-block schema that every model-provider adapter and the +// frontend protocol exchange in place of a plain string. +// +// docs/specifications/model/data-types.md's "Canonical message & +// content-block schema" section is the primary owner of this shape: a +// Message carries `repeated content.v1.ContentBlock content`, never a bare +// string, and a ContentBlock is a typed oneof over exactly seven variants — +// text, tool_use, tool_result, image, thinking, redacted_thinking, and +// document. docs/specifications/frontend/frontend-protocol.md's +// ClientEvent.UserMessage note makes the same point from the frontend +// side: UserMessage.content is `repeated pluggableharness.content.v1.ContentBlock`, +// and field 1 — the message's original bare `text` field — is `reserved` +// and MUST NOT be reused, precisely so a frontend has an entry point for +// non-text input (e.g. a pasted image) that a plain string never could +// represent. +// +// This package exists because the generated oneof-of-message shape is +// verbose to construct by hand at every call site — a plugin author +// wanting to emit a single text block would otherwise write out +// &contentv1.ContentBlock{Block: &contentv1.ContentBlock_Text{Text: +// &contentv1.TextBlock{Text: "..."}}} themselves. The functions in +// blocks.go do that wrapping once, and options.go adds a functional +// option for the one variant — DocumentBlock's optional filename — that +// needs one. +package content diff --git a/pkg/content/options.go b/pkg/content/options.go new file mode 100644 index 0000000..8ffc550 --- /dev/null +++ b/pkg/content/options.go @@ -0,0 +1,25 @@ +package content + +// Option configures an optional field on a content-block builder that +// takes one. DocumentBlock.filename (proto/v1/content.pb.go's +// DocumentBlock.Filename) is the only such field today: it's a MAY per +// docs/specifications/model/data-types.md's canonical-schema note ("MUST +// be supported ... carrying data: bytes, media_type: string, and an +// optional filename"), so Document does not take it as a required +// positional argument. +type Option func(*documentOptions) + +// documentOptions collects the optional fields Document accepts. +type documentOptions struct { + filename *string +} + +// WithFilename sets DocumentBlock's optional filename — the document's +// original filename, when known, which several vendors surface to the +// model as a citation/reference label (proto/v1/content.pb.go's +// DocumentBlock.Filename doc comment). +func WithFilename(name string) Option { + return func(o *documentOptions) { + o.filename = &name + } +} diff --git a/pkg/content/proto/v1/content.pb.go b/pkg/content/proto/v1/types.pb.go similarity index 87% rename from pkg/content/proto/v1/content.pb.go rename to pkg/content/proto/v1/types.pb.go index c7e8ace..bb64370 100644 --- a/pkg/content/proto/v1/content.pb.go +++ b/pkg/content/proto/v1/types.pb.go @@ -2,7 +2,7 @@ // versions: // protoc-gen-go v1.36.11 // protoc (unknown) -// source: pluggableharness/content/v1/content.proto +// source: pluggableharness/content/v1/types.proto // Package pluggableharness.content.v1 defines the canonical content-block message // schema described in specifications/model.md §5 — the state backend's @@ -82,11 +82,11 @@ func (x Role) String() string { } func (Role) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_content_v1_content_proto_enumTypes[0].Descriptor() + return file_pluggableharness_content_v1_types_proto_enumTypes[0].Descriptor() } func (Role) Type() protoreflect.EnumType { - return &file_pluggableharness_content_v1_content_proto_enumTypes[0] + return &file_pluggableharness_content_v1_types_proto_enumTypes[0] } func (x Role) Number() protoreflect.EnumNumber { @@ -95,7 +95,7 @@ func (x Role) Number() protoreflect.EnumNumber { // Deprecated: Use Role.Descriptor instead. func (Role) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_content_v1_content_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_content_v1_types_proto_rawDescGZIP(), []int{0} } // Stability hints whether a ContextSection's content changes turn to turn, @@ -144,11 +144,11 @@ func (x Stability) String() string { } func (Stability) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_content_v1_content_proto_enumTypes[1].Descriptor() + return file_pluggableharness_content_v1_types_proto_enumTypes[1].Descriptor() } func (Stability) Type() protoreflect.EnumType { - return &file_pluggableharness_content_v1_content_proto_enumTypes[1] + return &file_pluggableharness_content_v1_types_proto_enumTypes[1] } func (x Stability) Number() protoreflect.EnumNumber { @@ -157,7 +157,7 @@ func (x Stability) Number() protoreflect.EnumNumber { // Deprecated: Use Stability.Descriptor instead. func (Stability) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_content_v1_content_proto_rawDescGZIP(), []int{1} + return file_pluggableharness_content_v1_types_proto_rawDescGZIP(), []int{1} } // Message is one turn in the canonical conversation history: a role plus @@ -204,7 +204,7 @@ type Message struct { func (x *Message) Reset() { *x = Message{} - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[0] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -216,7 +216,7 @@ func (x *Message) String() string { func (*Message) ProtoMessage() {} func (x *Message) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[0] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -229,7 +229,7 @@ func (x *Message) ProtoReflect() protoreflect.Message { // Deprecated: Use Message.ProtoReflect.Descriptor instead. func (*Message) Descriptor() ([]byte, []int) { - return file_pluggableharness_content_v1_content_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_content_v1_types_proto_rawDescGZIP(), []int{0} } func (x *Message) GetRole() Role { @@ -291,7 +291,7 @@ type ContentBlock struct { func (x *ContentBlock) Reset() { *x = ContentBlock{} - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[1] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -303,7 +303,7 @@ func (x *ContentBlock) String() string { func (*ContentBlock) ProtoMessage() {} func (x *ContentBlock) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[1] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -316,7 +316,7 @@ func (x *ContentBlock) ProtoReflect() protoreflect.Message { // Deprecated: Use ContentBlock.ProtoReflect.Descriptor instead. func (*ContentBlock) Descriptor() ([]byte, []int) { - return file_pluggableharness_content_v1_content_proto_rawDescGZIP(), []int{1} + return file_pluggableharness_content_v1_types_proto_rawDescGZIP(), []int{1} } func (x *ContentBlock) GetBlock() isContentBlock_Block { @@ -447,7 +447,7 @@ type TextBlock struct { func (x *TextBlock) Reset() { *x = TextBlock{} - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[2] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -459,7 +459,7 @@ func (x *TextBlock) String() string { func (*TextBlock) ProtoMessage() {} func (x *TextBlock) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[2] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -472,7 +472,7 @@ func (x *TextBlock) ProtoReflect() protoreflect.Message { // Deprecated: Use TextBlock.ProtoReflect.Descriptor instead. func (*TextBlock) Descriptor() ([]byte, []int) { - return file_pluggableharness_content_v1_content_proto_rawDescGZIP(), []int{2} + return file_pluggableharness_content_v1_types_proto_rawDescGZIP(), []int{2} } func (x *TextBlock) GetText() string { @@ -503,7 +503,7 @@ type ToolUseBlock struct { func (x *ToolUseBlock) Reset() { *x = ToolUseBlock{} - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[3] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -515,7 +515,7 @@ func (x *ToolUseBlock) String() string { func (*ToolUseBlock) ProtoMessage() {} func (x *ToolUseBlock) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[3] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -528,7 +528,7 @@ func (x *ToolUseBlock) ProtoReflect() protoreflect.Message { // Deprecated: Use ToolUseBlock.ProtoReflect.Descriptor instead. func (*ToolUseBlock) Descriptor() ([]byte, []int) { - return file_pluggableharness_content_v1_content_proto_rawDescGZIP(), []int{3} + return file_pluggableharness_content_v1_types_proto_rawDescGZIP(), []int{3} } func (x *ToolUseBlock) GetId() string { @@ -575,7 +575,7 @@ type ToolResultBlock struct { func (x *ToolResultBlock) Reset() { *x = ToolResultBlock{} - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[4] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -587,7 +587,7 @@ func (x *ToolResultBlock) String() string { func (*ToolResultBlock) ProtoMessage() {} func (x *ToolResultBlock) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[4] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -600,7 +600,7 @@ func (x *ToolResultBlock) ProtoReflect() protoreflect.Message { // Deprecated: Use ToolResultBlock.ProtoReflect.Descriptor instead. func (*ToolResultBlock) Descriptor() ([]byte, []int) { - return file_pluggableharness_content_v1_content_proto_rawDescGZIP(), []int{4} + return file_pluggableharness_content_v1_types_proto_rawDescGZIP(), []int{4} } func (x *ToolResultBlock) GetToolUseId() string { @@ -640,7 +640,7 @@ type ImageBlock struct { func (x *ImageBlock) Reset() { *x = ImageBlock{} - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[5] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -652,7 +652,7 @@ func (x *ImageBlock) String() string { func (*ImageBlock) ProtoMessage() {} func (x *ImageBlock) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[5] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -665,7 +665,7 @@ func (x *ImageBlock) ProtoReflect() protoreflect.Message { // Deprecated: Use ImageBlock.ProtoReflect.Descriptor instead. func (*ImageBlock) Descriptor() ([]byte, []int) { - return file_pluggableharness_content_v1_content_proto_rawDescGZIP(), []int{5} + return file_pluggableharness_content_v1_types_proto_rawDescGZIP(), []int{5} } func (x *ImageBlock) GetData() []byte { @@ -702,7 +702,7 @@ type ThinkingBlock struct { func (x *ThinkingBlock) Reset() { *x = ThinkingBlock{} - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[6] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -714,7 +714,7 @@ func (x *ThinkingBlock) String() string { func (*ThinkingBlock) ProtoMessage() {} func (x *ThinkingBlock) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[6] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -727,7 +727,7 @@ func (x *ThinkingBlock) ProtoReflect() protoreflect.Message { // Deprecated: Use ThinkingBlock.ProtoReflect.Descriptor instead. func (*ThinkingBlock) Descriptor() ([]byte, []int) { - return file_pluggableharness_content_v1_content_proto_rawDescGZIP(), []int{6} + return file_pluggableharness_content_v1_types_proto_rawDescGZIP(), []int{6} } func (x *ThinkingBlock) GetText() string { @@ -758,7 +758,7 @@ type RedactedThinkingBlock struct { func (x *RedactedThinkingBlock) Reset() { *x = RedactedThinkingBlock{} - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[7] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -770,7 +770,7 @@ func (x *RedactedThinkingBlock) String() string { func (*RedactedThinkingBlock) ProtoMessage() {} func (x *RedactedThinkingBlock) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[7] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -783,7 +783,7 @@ func (x *RedactedThinkingBlock) ProtoReflect() protoreflect.Message { // Deprecated: Use RedactedThinkingBlock.ProtoReflect.Descriptor instead. func (*RedactedThinkingBlock) Descriptor() ([]byte, []int) { - return file_pluggableharness_content_v1_content_proto_rawDescGZIP(), []int{7} + return file_pluggableharness_content_v1_types_proto_rawDescGZIP(), []int{7} } func (x *RedactedThinkingBlock) GetData() []byte { @@ -815,7 +815,7 @@ type DocumentBlock struct { func (x *DocumentBlock) Reset() { *x = DocumentBlock{} - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[8] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -827,7 +827,7 @@ func (x *DocumentBlock) String() string { func (*DocumentBlock) ProtoMessage() {} func (x *DocumentBlock) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[8] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -840,7 +840,7 @@ func (x *DocumentBlock) ProtoReflect() protoreflect.Message { // Deprecated: Use DocumentBlock.ProtoReflect.Descriptor instead. func (*DocumentBlock) Descriptor() ([]byte, []int) { - return file_pluggableharness_content_v1_content_proto_rawDescGZIP(), []int{8} + return file_pluggableharness_content_v1_types_proto_rawDescGZIP(), []int{8} } func (x *DocumentBlock) GetData() []byte { @@ -897,7 +897,7 @@ type ContextSection struct { func (x *ContextSection) Reset() { *x = ContextSection{} - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[9] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -909,7 +909,7 @@ func (x *ContextSection) String() string { func (*ContextSection) ProtoMessage() {} func (x *ContextSection) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_content_v1_content_proto_msgTypes[9] + mi := &file_pluggableharness_content_v1_types_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -922,7 +922,7 @@ func (x *ContextSection) ProtoReflect() protoreflect.Message { // Deprecated: Use ContextSection.ProtoReflect.Descriptor instead. func (*ContextSection) Descriptor() ([]byte, []int) { - return file_pluggableharness_content_v1_content_proto_rawDescGZIP(), []int{9} + return file_pluggableharness_content_v1_types_proto_rawDescGZIP(), []int{9} } func (x *ContextSection) GetProvider() string { @@ -967,11 +967,11 @@ func (x *ContextSection) GetTruncated() bool { return false } -var File_pluggableharness_content_v1_content_proto protoreflect.FileDescriptor +var File_pluggableharness_content_v1_types_proto protoreflect.FileDescriptor -const file_pluggableharness_content_v1_content_proto_rawDesc = "" + +const file_pluggableharness_content_v1_types_proto_rawDesc = "" + "\n" + - ")pluggableharness/content/v1/content.proto\x12\x1bpluggableharness.content.v1\x1a\x1cgoogle/protobuf/struct.proto\"\xb4\x02\n" + + "'pluggableharness/content/v1/types.proto\x12\x1bpluggableharness.content.v1\x1a\x1cgoogle/protobuf/struct.proto\"\xb4\x02\n" + "\aMessage\x125\n" + "\x04role\x18\x01 \x01(\x0e2!.pluggableharness.content.v1.RoleR\x04role\x12C\n" + "\acontent\x18\x02 \x03(\v2).pluggableharness.content.v1.ContentBlockR\acontent\x12\x0e\n" + @@ -1033,20 +1033,20 @@ const file_pluggableharness_content_v1_content_proto_rawDesc = "" + "\x11STABILITY_DYNAMIC\x10\x02BBZ@github.com/pluggableharness/agent/pkg/content/proto/v1;contentv1b\x06proto3" var ( - file_pluggableharness_content_v1_content_proto_rawDescOnce sync.Once - file_pluggableharness_content_v1_content_proto_rawDescData []byte + file_pluggableharness_content_v1_types_proto_rawDescOnce sync.Once + file_pluggableharness_content_v1_types_proto_rawDescData []byte ) -func file_pluggableharness_content_v1_content_proto_rawDescGZIP() []byte { - file_pluggableharness_content_v1_content_proto_rawDescOnce.Do(func() { - file_pluggableharness_content_v1_content_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_content_v1_content_proto_rawDesc), len(file_pluggableharness_content_v1_content_proto_rawDesc))) +func file_pluggableharness_content_v1_types_proto_rawDescGZIP() []byte { + file_pluggableharness_content_v1_types_proto_rawDescOnce.Do(func() { + file_pluggableharness_content_v1_types_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_content_v1_types_proto_rawDesc), len(file_pluggableharness_content_v1_types_proto_rawDesc))) }) - return file_pluggableharness_content_v1_content_proto_rawDescData + return file_pluggableharness_content_v1_types_proto_rawDescData } -var file_pluggableharness_content_v1_content_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_pluggableharness_content_v1_content_proto_msgTypes = make([]protoimpl.MessageInfo, 10) -var file_pluggableharness_content_v1_content_proto_goTypes = []any{ +var file_pluggableharness_content_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_pluggableharness_content_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_pluggableharness_content_v1_types_proto_goTypes = []any{ (Role)(0), // 0: pluggableharness.content.v1.Role (Stability)(0), // 1: pluggableharness.content.v1.Stability (*Message)(nil), // 2: pluggableharness.content.v1.Message @@ -1061,7 +1061,7 @@ var file_pluggableharness_content_v1_content_proto_goTypes = []any{ (*ContextSection)(nil), // 11: pluggableharness.content.v1.ContextSection (*structpb.Struct)(nil), // 12: google.protobuf.Struct } -var file_pluggableharness_content_v1_content_proto_depIdxs = []int32{ +var file_pluggableharness_content_v1_types_proto_depIdxs = []int32{ 0, // 0: pluggableharness.content.v1.Message.role:type_name -> pluggableharness.content.v1.Role 3, // 1: pluggableharness.content.v1.Message.content:type_name -> pluggableharness.content.v1.ContentBlock 4, // 2: pluggableharness.content.v1.ContentBlock.text:type_name -> pluggableharness.content.v1.TextBlock @@ -1082,13 +1082,13 @@ var file_pluggableharness_content_v1_content_proto_depIdxs = []int32{ 0, // [0:13] is the sub-list for field type_name } -func init() { file_pluggableharness_content_v1_content_proto_init() } -func file_pluggableharness_content_v1_content_proto_init() { - if File_pluggableharness_content_v1_content_proto != nil { +func init() { file_pluggableharness_content_v1_types_proto_init() } +func file_pluggableharness_content_v1_types_proto_init() { + if File_pluggableharness_content_v1_types_proto != nil { return } - file_pluggableharness_content_v1_content_proto_msgTypes[0].OneofWrappers = []any{} - file_pluggableharness_content_v1_content_proto_msgTypes[1].OneofWrappers = []any{ + file_pluggableharness_content_v1_types_proto_msgTypes[0].OneofWrappers = []any{} + file_pluggableharness_content_v1_types_proto_msgTypes[1].OneofWrappers = []any{ (*ContentBlock_Text)(nil), (*ContentBlock_ToolUse)(nil), (*ContentBlock_ToolResult)(nil), @@ -1097,23 +1097,23 @@ func file_pluggableharness_content_v1_content_proto_init() { (*ContentBlock_RedactedThinking)(nil), (*ContentBlock_Document)(nil), } - file_pluggableharness_content_v1_content_proto_msgTypes[8].OneofWrappers = []any{} + file_pluggableharness_content_v1_types_proto_msgTypes[8].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_content_v1_content_proto_rawDesc), len(file_pluggableharness_content_v1_content_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_content_v1_types_proto_rawDesc), len(file_pluggableharness_content_v1_types_proto_rawDesc)), NumEnums: 2, NumMessages: 10, NumExtensions: 0, NumServices: 0, }, - GoTypes: file_pluggableharness_content_v1_content_proto_goTypes, - DependencyIndexes: file_pluggableharness_content_v1_content_proto_depIdxs, - EnumInfos: file_pluggableharness_content_v1_content_proto_enumTypes, - MessageInfos: file_pluggableharness_content_v1_content_proto_msgTypes, + GoTypes: file_pluggableharness_content_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_content_v1_types_proto_depIdxs, + EnumInfos: file_pluggableharness_content_v1_types_proto_enumTypes, + MessageInfos: file_pluggableharness_content_v1_types_proto_msgTypes, }.Build() - File_pluggableharness_content_v1_content_proto = out.File - file_pluggableharness_content_v1_content_proto_goTypes = nil - file_pluggableharness_content_v1_content_proto_depIdxs = nil + File_pluggableharness_content_v1_types_proto = out.File + file_pluggableharness_content_v1_types_proto_goTypes = nil + file_pluggableharness_content_v1_types_proto_depIdxs = nil } diff --git a/pkg/context/capabilities.go b/pkg/context/capabilities.go new file mode 100644 index 0000000..765a184 --- /dev/null +++ b/pkg/context/capabilities.go @@ -0,0 +1,67 @@ +package context + +import ( + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" +) + +// capabilitiesOptions collects CapabilitiesOption values before +// NewCapabilities assembles them. Defaults match Capabilities' own +// wire defaults: not a compactor, no slash commands, no supported hook +// points. +type capabilitiesOptions struct { + compactor bool + slashCommands []*commonv1.PromptExpansionSpec + supportedHookPoints []commonv1.HookPoint +} + +// CapabilitiesOption configures one optional field of a +// Capabilities built by NewCapabilities. +type CapabilitiesOption func(*capabilitiesOptions) + +// WithCompactor declares this provider a compactor +// (data-types.md#ordering--chaining): it MAY rewrite, merge, or drop +// other providers' sections in the chain it receives, and MAY receive +// Request.ConversationHistory and return +// Contribution.RewrittenHistory. +func WithCompactor() CapabilitiesOption { + return func(o *capabilitiesOptions) { o.compactor = true } +} + +// WithSlashCommands declares the prompt-expansion slash commands this +// provider contributes (protocol.md#getcapabilities). +func WithSlashCommands(specs ...*commonv1.PromptExpansionSpec) CapabilitiesOption { + return func(o *capabilitiesOptions) { o.slashCommands = specs } +} + +// WithSupportedHookPoints declares which hook points (beyond +// context-assemble itself) this provider subscribes +// HookSubscriberService.DispatchHook to. +func WithSupportedHookPoints(points ...commonv1.HookPoint) CapabilitiesOption { + return func(o *capabilitiesOptions) { o.supportedHookPoints = points } +} + +// NewCapabilities builds a *Capabilities for a Provider's +// GetCapabilities response. defaultTokenBudget and stability are the two +// MUST-set fields (protocol.md#getcapabilities); configSchema is this +// provider's agent.hcl config schema, typically built with +// pkg/config.Schema — pass an empty schema (pkg/config.Schema() with no +// attributes) for a provider with no configuration, never nil, so +// GetCapabilities' ConfigSchema field is always populated per +// protocol.md#getcapabilities ("MUST include the provider's +// ConfigSchema"). Optional properties (Compactor, SlashCommands, +// SupportedHookPoints) are set via CapabilitiesOption. +func NewCapabilities(defaultTokenBudget int64, stability Stability, configSchema *configv1.ConfigSchema, opts ...CapabilitiesOption) *Capabilities { + var o capabilitiesOptions + for _, opt := range opts { + opt(&o) + } + return &Capabilities{ + DefaultTokenBudget: defaultTokenBudget, + Stability: stability, + Compactor: o.compactor, + SlashCommands: o.slashCommands, + ConfigSchema: configSchema, + SupportedHookPoints: o.supportedHookPoints, + } +} diff --git a/pkg/context/capabilities_test.go b/pkg/context/capabilities_test.go new file mode 100644 index 0000000..151550d --- /dev/null +++ b/pkg/context/capabilities_test.go @@ -0,0 +1,62 @@ +package context_test + +import ( + "testing" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + pluggablecontext "github.com/pluggableharness/agent/pkg/context" +) + +func TestNewCapabilities_defaults(t *testing.T) { + t.Parallel() + + schema := &configv1.ConfigSchema{} + caps := pluggablecontext.NewCapabilities(2000, pluggablecontext.StabilityStatic, schema) + + if caps.DefaultTokenBudget != 2000 { + t.Errorf("DefaultTokenBudget = %d, want 2000", caps.DefaultTokenBudget) + } + if caps.Stability != pluggablecontext.StabilityStatic { + t.Errorf("Stability = %v, want StabilityStatic", caps.Stability) + } + if caps.Compactor { + t.Error("Compactor = true, want false (default)") + } + if caps.ConfigSchema != schema { + t.Errorf("ConfigSchema = %v, want %v", caps.ConfigSchema, schema) + } + if len(caps.SlashCommands) != 0 { + t.Errorf("SlashCommands = %v, want empty", caps.SlashCommands) + } + if len(caps.SupportedHookPoints) != 0 { + t.Errorf("SupportedHookPoints = %v, want empty", caps.SupportedHookPoints) + } +} + +func TestNewCapabilities_withOptions(t *testing.T) { + t.Parallel() + + schema := &configv1.ConfigSchema{} + specs := []*commonv1.PromptExpansionSpec{{Name: "review", Template: "review {{.arg}}"}} + points := []commonv1.HookPoint{commonv1.HookPoint_HOOK_POINT_SESSION_START, commonv1.HookPoint_HOOK_POINT_SESSION_END} + + caps := pluggablecontext.NewCapabilities( + 1500, + pluggablecontext.StabilityDynamic, + schema, + pluggablecontext.WithCompactor(), + pluggablecontext.WithSlashCommands(specs...), + pluggablecontext.WithSupportedHookPoints(points...), + ) + + if !caps.Compactor { + t.Error("Compactor = false, want true") + } + if len(caps.SlashCommands) != 1 || caps.SlashCommands[0].GetName() != "review" { + t.Errorf("SlashCommands = %v, want [review]", caps.SlashCommands) + } + if len(caps.SupportedHookPoints) != 2 { + t.Errorf("SupportedHookPoints = %v, want 2 entries", caps.SupportedHookPoints) + } +} diff --git a/pkg/context/context.go b/pkg/context/context.go new file mode 100644 index 0000000..5e14a33 --- /dev/null +++ b/pkg/context/context.go @@ -0,0 +1,336 @@ +package context + +import ( + "context" + "fmt" + + "google.golang.org/protobuf/types/known/structpb" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + "github.com/pluggableharness/agent/pkg/kernel" + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// Stability is a Section's or Capabilities' turn-to-turn +// change hint (data-types.md#stability-hint--cache-prefix-ordering): +// StabilityStatic for content unchanged for the session (e.g. a repo +// convention file), StabilityDynamic for content that may differ turn to +// turn (e.g. git status or a file tree). Static-stability content SHOULD +// be declared by providers earlier in agent.hcl declaration order than +// dynamic-stability content, to preserve prompt-cache prefix reuse. +type Stability int32 + +const ( + // StabilityUnspecified is the zero value. Never valid on a value a + // provider actually returns; its presence means the author forgot to + // set Stability. + StabilityUnspecified Stability = iota + // StabilityStatic marks content unchanged for the whole session. + StabilityStatic + // StabilityDynamic marks content that may differ turn to turn. + StabilityDynamic +) + +// String returns s's spec vocabulary name ("static", "dynamic", +// "unspecified"), for logging and error messages. +func (s Stability) String() string { + switch s { + case StabilityStatic: + return "static" + case StabilityDynamic: + return "dynamic" + default: + return "unspecified" + } +} + +// Capabilities reports a context provider's static properties — +// the author-facing analogue of contextv1.ContextCapabilities +// (protocol.md#getcapabilities). DefaultTokenBudget and Stability MUST be +// set by every provider; Stability and Compactor MUST be re-queryable +// cheaply and MUST NOT depend on a live read of the content source. Build +// one with NewCapabilities rather than a struct literal. +type Capabilities struct { + // DefaultTokenBudget is the token cap this provider requests if + // agent.hcl does not override it. MUST be set. + DefaultTokenBudget int64 + // Stability declares whether this provider's contributed content + // changes turn to turn. MUST be set. + Stability Stability + // Compactor declares whether this provider MAY rewrite, merge, or + // drop other providers' sections in the chain it receives, and MAY + // receive ConversationHistory on Request and return + // RewrittenHistory. Defaults to false — most providers are not + // compactors. See data-types.md#ordering--chaining. + Compactor bool + // SlashCommands are prompt-expansion slash commands this provider + // contributes. MAY be empty. + SlashCommands []*commonv1.PromptExpansionSpec + // ConfigSchema is this provider's agent.hcl config schema, built via + // pkg/config. MUST be set (MAY be an empty schema for a provider + // with no configuration). + ConfigSchema *configv1.ConfigSchema + // SupportedHookPoints are the hook points (beyond context-assemble + // itself) this provider subscribes HookSubscriberService.DispatchHook + // to. MAY be empty. + SupportedHookPoints []commonv1.HookPoint +} + +// Section is one provider's contribution to the assembled prompt +// context — the author-facing analogue of contentv1.Section +// (data-types.md#contextsection). Content is a plain string because v1 is +// text-only: a non-text content block MUST be rejected, not silently +// dropped, so this type simply has no way to express one. +type Section struct { + // Provider is the producing plugin's declared name — the identity + // key a provider uses to re-find and replace its own prior + // section(s) in the chain. MUST match the plugin's own agent.hcl + // declared name. + Provider string + // Label MUST be set — the kernel wraps every section in a clearly + // delimited boundary using it when concatenating the chain into the + // final prompt (data-types.md#labeling). + Label string + // Content is this section's text. MUST be computed via CountTokens + // (below) for the Tokens field, and MUST fit within the + // Request.TokenBudget this provider was given — the provider + // itself, not the kernel, performs any needed reduction + // (data-types.md#budget-mechanics). + Content string + // Tokens MUST be computed via the kernel's CountTokens callback + // primitive — see CountTokens below — never a provider-local + // heuristic estimate. + Tokens int64 + // Stability MUST be set. + Stability Stability + // Truncated records whether this section was cut down to fit its + // budget. Setting it true is NOT itself sufficient to satisfy the + // budget constraint — Content itself must actually fit. + Truncated bool +} + +// Request is Contribute's request — the author-facing analogue of +// contextv1.ContextRequest (data-types.md#contextrequest), delivered once +// per context-assemble firing (at least once per turn, before each model +// call). +type Request struct { + SessionID string + ParentSessionID string + // TurnID identifies which turn this firing is for, a ULID string. + TurnID string + // TokenBudget is the kernel-computed allocation for this provider's + // own contribution this call — agent.hcl's declared override if + // present, else this provider's own Capabilities.DefaultTokenBudget. + TokenBudget int64 + // ModelTarget describes the model this context is being assembled + // for. Note it carries no provider name (only id, context_window, + // effective_ceiling) — CountTokens below cannot derive a fully + // qualified model.v1.ModelRef from it alone, so it routes through + // the kernel's documented fallback heuristic unless the caller + // supplies its own ModelRef via the package-level CountTokens + // function. + ModelTarget *modelv1.ModelTarget + // FilesTouched MAY be empty (e.g. turn 0 / session-start). A + // provider MAY react to it for JIT-scoped, subdirectory-narrowed + // contributions (README.md#firing-cadence--jit-loading). + FilesTouched []string + WorkingDirectory string + // PriorSections is the accumulated output of earlier providers in + // this hook's declaration-order chain. A non-compactor provider MUST + // only append or edit its OWN section(s) (matched by Provider name) + // in the chain it returns from Contribute — see + // Contribution.Sections and CheckOwnSectionOnly below. + PriorSections []*Section + // ConversationHistory arrives populated ONLY when this provider's + // own Capabilities.Compactor == true; for every other + // provider it is nil, indistinguishable from "not provided". + ConversationHistory []*contentv1.Message + // HistoryTokens is the kernel-computed current conversation-history + // token total, carried on every firing (not just compactor-directed + // ones) — see data-types.md#compactor-timing-signals. + HistoryTokens int64 + // AssembledTokensLastTurn is the kernel-computed total assembled + // context size of the previous turn. + AssembledTokensLastTurn int64 + + // CountTokens is bound by Service.Contribute to the kernel's + // CountTokens callback (kernel-callbacks.md#counttokens) — call this + // to compute a Section.Tokens value rather than inventing a + // local heuristic. Always non-nil when Contribute is invoked through + // a *Service; a hand-rolled unit test that constructs a + // *Request directly MUST set it (e.g. to a fake, or to a + // closure built from the package-level CountTokens function against + // a bufconn kernel-callback test server). + CountTokens func(ctx context.Context, text string) (int64, error) +} + +// Contribution is Contribute's response — the author-facing +// analogue of contextv1.ContextContribution +// (data-types.md#contextcontribution). +type Contribution struct { + // Sections MUST be the FULL accumulated chain, in declaration order, + // including this provider's own new/updated section(s) — this + // provider's own section appended to (or edited within) + // Request.PriorSections, NEVER a delta. A non-compactor + // provider mutating a section it doesn't own is a scope_violation: + // the kernel discards the entire response for the turn. See + // CheckOwnSectionOnly. + Sections []*Section + // RewrittenHistory MAY be included by a compactor provider (only) + // alongside its section contribution. When present, the kernel + // replaces the turn's conversation history with this value before + // the next model call. + RewrittenHistory []*contentv1.Message +} + +// Provider is the interface a context provider plugin author implements. +// NewService adapts a Provider into a real +// pluggableharness.context.v1.ContextService gRPC server. +type Provider interface { + // GetCapabilities reports this provider's static properties. MUST be + // cheap and side-effect-free — see Capabilities.Stability and + // Capabilities.Compactor. + GetCapabilities(ctx context.Context) (*Capabilities, error) + // Configure delivers this provider's agent.hcl config block, already + // decoded via the schema-to-cty bridge into config. MUST reject with + // a structured error (see errors.go) if a declared source path/glob + // cannot be resolved to anything on disk, rather than deferring to a + // silent-empty Contribute at first call. + Configure(ctx context.Context, config *structpb.Struct) error + // Contribute is the context-assemble RPC's author-facing entry + // point, invoked at least once per turn, before each model call. + // + // req.PriorSections is the accumulated chain from every earlier + // provider in this hook's declaration-order chain. + // Contribution.Sections in the return value MUST be the FULL + // accumulated chain — req.PriorSections plus (or with) this + // provider's own section(s) — NEVER just this provider's own + // addition. A non-compactor implementation MUST only append or edit + // sections whose Provider field matches this plugin's own declared + // name; see CheckOwnSectionOnly for a helper to verify that + // contract in this provider's own tests. + Contribute(ctx context.Context, req *Request) (*Contribution, error) +} + +// Renderer is the optional interface a Provider MAY additionally +// implement to support the Render RPC (protocol.md#render) — e.g. to +// render an injected CLAUDE.md section collapsed by default in a +// transcript view. A Provider that doesn't implement Renderer causes +// Service.Render to return codes.Unimplemented, and the kernel falls back +// to its generic default rendering. +type Renderer interface { + Render(ctx context.Context, req *RenderRequest) (*renderv1.RenderTree, error) +} + +// RenderRequest is Render's request — the same opaque payload/schema_version +// shape as every other category's Render RPC +// (frontend/render-tree.md#schema-versioning). Kept as a thin alias over +// the generated fields rather than a domain type: the payload is opaque by +// design (.claude/rules/grpc.md's Emit->Render->Paint carve-out), so there +// is nothing to translate. +type RenderRequest struct { + // Payload is the opaque, previously-Emit'd bytes to render. + Payload []byte + // SchemaVersion identifies which shape Payload was encoded with. A + // Renderer MUST branch on this rather than sniffing Payload's shape, + // and MUST keep decoding every schema_version it has ever emitted. + SchemaVersion string +} + +// CheckOwnSectionOnly reports a scope_violation-shaped *Error if +// chain diverges from prior anywhere other than the sections owned by +// providerName, unless compactor is true. This mirrors +// data-types.md#ordering--chaining's own-section-only rule structurally: +// a non-compactor context provider MUST only append or edit its own +// section(s). Not invoked automatically as part of the RPC contract (the +// kernel is the actual enforcement authority — a plugin cannot make the +// kernel accept or reject its own response), but useful as a check in a +// provider's own Contribute tests, and Service.Contribute calls it +// defensively (log-only) as well. +func CheckOwnSectionOnly(prior, chain []*Section, providerName string, compactor bool) error { + if compactor { + return nil + } + if len(chain) < len(prior) { + return &Error{ + Category: ErrorCategoryScopeViolation, + Message: fmt.Sprintf("context: returned chain has %d section(s), fewer than the %d it was given, without compactor capability", len(chain), len(prior)), + } + } + for i, p := range prior { + if p.Provider == providerName { + continue + } + c := chain[i] + if !sectionEqual(p, c) { + return &Error{ + Category: ErrorCategoryScopeViolation, + Message: fmt.Sprintf("context: section %d (provider %q) was mutated by non-owning provider %q", i, p.Provider, providerName), + } + } + } + for i := len(prior); i < len(chain); i++ { + if chain[i].Provider != providerName { + return &Error{ + Category: ErrorCategoryScopeViolation, + Message: fmt.Sprintf("context: appended section %d has provider %q, want %q", i, chain[i].Provider, providerName), + } + } + } + return nil +} + +// sectionEqual reports whether a and b carry identical field values. +func sectionEqual(a, b *Section) bool { + if a == nil || b == nil { + return a == b + } + return a.Provider == b.Provider && + a.Label == b.Label && + a.Content == b.Content && + a.Tokens == b.Tokens && + a.Stability == b.Stability && + a.Truncated == b.Truncated +} + +// CountTokens resolves text's token count via the kernel's CountTokens +// callback primitive (kernel-callbacks.md#counttokens) — the ONLY +// sanctioned way a Section.Tokens value may be produced +// (data-types.md#contextsection: "never a provider-local heuristic +// estimate"). modelRef MAY be nil, in which case the kernel's single +// documented fallback heuristic applies +// (kernel-callbacks.md#the-fallback-heuristic: +// ceil(utf8_byte_length/4)) rather than a real vendor tokenizer. This is +// the function Request.CountTokens is built from; call it directly +// only when a provider needs a model.v1.ModelRef more specific than what +// Request.ModelTarget alone can supply (ModelTarget carries no +// provider name). +func CountTokens(ctx context.Context, cb *plugin.Callback, modelRef *modelv1.ModelRef, text string) (int64, error) { + client, err := cb.Client(ctx) + if err != nil { + return 0, fmt.Errorf("context: count tokens: %w", err) + } + return countTokens(ctx, client, modelRef, text) +} + +// countTokens is CountTokens' shared implementation over an +// already-dialed *kernel.Client, reused by Service.Contribute so it +// doesn't need to re-dial the callback broker for every provider it +// wires a Request.CountTokens closure for. +func countTokens(ctx context.Context, client *kernel.Client, modelRef *modelv1.ModelRef, text string) (int64, error) { + result, err := client.CountTokens(ctx, &kernelv1.CountTokensRequest{ + Content: []*contentv1.ContentBlock{ + {Block: &contentv1.ContentBlock_Text{Text: &contentv1.TextBlock{Text: text}}}, + }, + ModelRef: modelRef, + }) + if err != nil { + return 0, fmt.Errorf("context: count tokens: %w", err) + } + return result.GetCount(), nil +} diff --git a/pkg/context/context_test.go b/pkg/context/context_test.go new file mode 100644 index 0000000..633ca97 --- /dev/null +++ b/pkg/context/context_test.go @@ -0,0 +1,148 @@ +package context_test + +import ( + "context" + "errors" + "testing" + + "google.golang.org/protobuf/types/known/structpb" + + pluggablecontext "github.com/pluggableharness/agent/pkg/context" + "github.com/pluggableharness/agent/pkg/plugin" +) + +func TestStability_String(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + s pluggablecontext.Stability + want string + }{ + {"static", pluggablecontext.StabilityStatic, "static"}, + {"dynamic", pluggablecontext.StabilityDynamic, "dynamic"}, + {"unspecified", pluggablecontext.StabilityUnspecified, "unspecified"}, + {"out of range", pluggablecontext.Stability(99), "unspecified"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := tt.s.String(); got != tt.want { + t.Errorf("Stability(%d).String() = %q, want %q", tt.s, got, tt.want) + } + }) + } +} + +// fakeProvider is a hand-written pluggablecontext.Provider fake +// (go-testing.md: fakes, not mocking frameworks). +type fakeProvider struct { + getCapabilitiesFunc func() (*pluggablecontext.Capabilities, error) + configureFunc func(*structpb.Struct) error + contributeFunc func(*pluggablecontext.Request) (*pluggablecontext.Contribution, error) +} + +func (f *fakeProvider) GetCapabilities(context.Context) (*pluggablecontext.Capabilities, error) { + if f.getCapabilitiesFunc != nil { + return f.getCapabilitiesFunc() + } + return &pluggablecontext.Capabilities{}, nil +} + +func (f *fakeProvider) Configure(_ context.Context, cfg *structpb.Struct) error { + if f.configureFunc != nil { + return f.configureFunc(cfg) + } + return nil +} + +func (f *fakeProvider) Contribute(_ context.Context, req *pluggablecontext.Request) (*pluggablecontext.Contribution, error) { + if f.contributeFunc != nil { + return f.contributeFunc(req) + } + return &pluggablecontext.Contribution{}, nil +} + +var _ pluggablecontext.Provider = (*fakeProvider)(nil) + +func TestCheckOwnSectionOnly(t *testing.T) { + t.Parallel() + + own := func(label string) *pluggablecontext.Section { + return &pluggablecontext.Section{Provider: "agents-md", Label: label, Content: "c"} + } + foreign := &pluggablecontext.Section{Provider: "claude-md", Label: "CLAUDE.md", Content: "conventions"} + + tests := []struct { + name string + prior []*pluggablecontext.Section + chain []*pluggablecontext.Section + compactor bool + wantErr bool + }{ + { + name: "valid append", + prior: []*pluggablecontext.Section{foreign}, + chain: []*pluggablecontext.Section{foreign, own("root")}, + }, + { + name: "valid edit of own section", + prior: []*pluggablecontext.Section{foreign, own("root")}, + chain: []*pluggablecontext.Section{foreign, own("root-edited")}, + }, + { + name: "foreign section mutated", + prior: []*pluggablecontext.Section{foreign}, + chain: []*pluggablecontext.Section{{Provider: "claude-md", Label: "changed", Content: "x"}}, + wantErr: true, + }, + { + name: "chain drops a prior section", + prior: []*pluggablecontext.Section{foreign, own("root")}, + chain: []*pluggablecontext.Section{own("root")}, + wantErr: true, + }, + { + name: "appended section has wrong provider", + prior: []*pluggablecontext.Section{foreign}, + chain: []*pluggablecontext.Section{foreign, {Provider: "someone-else", Label: "x"}}, + wantErr: true, + }, + { + name: "compactor may rewrite anything", + prior: []*pluggablecontext.Section{foreign, own("root")}, + chain: []*pluggablecontext.Section{{Provider: "claude-md", Label: "rewritten"}}, + compactor: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := pluggablecontext.CheckOwnSectionOnly(tt.prior, tt.chain, "agents-md", tt.compactor) + if (err != nil) != tt.wantErr { + t.Errorf("CheckOwnSectionOnly() error = %v, wantErr %v", err, tt.wantErr) + } + if err != nil { + var ctxErr *pluggablecontext.Error + if !errors.As(err, &ctxErr) { + t.Fatalf("CheckOwnSectionOnly() error type = %T, want *Error", err) + } + if ctxErr.Category != pluggablecontext.ErrorCategoryScopeViolation { + t.Errorf("CheckOwnSectionOnly() error category = %v, want ErrorCategoryScopeViolation", ctxErr.Category) + } + } + }) + } +} + +func TestCountTokens_dialFailure(t *testing.T) { + t.Parallel() + + cb := plugin.NewCallback() + _, err := pluggablecontext.CountTokens(t.Context(), cb, nil, "hello") + if err == nil { + t.Fatal("CountTokens() error = nil, want non-nil (callback broker unset)") + } +} diff --git a/pkg/context/convert.go b/pkg/context/convert.go new file mode 100644 index 0000000..869b631 --- /dev/null +++ b/pkg/context/convert.go @@ -0,0 +1,250 @@ +package context + +import ( + "errors" + "fmt" + "strings" + + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + contextv1 "github.com/pluggableharness/agent/pkg/context/proto/v1" +) + +// ErrNonTextContent is returned when a Section's wire content +// carries a non-text ContentBlock. v1 is text-only +// (data-types.md#contextsection, conformance.md's summary matrix): the +// kernel MUST reject a non-text block, not silently drop it, and this SDK +// treats the same condition as an invalid_request-shaped translation +// failure rather than attempting a lossy conversion. +var ErrNonTextContent = errors.New("context: non-text content block (text-only in v1)") + +// stabilityToProto converts a domain Stability to its wire enum value. +func stabilityToProto(s Stability) contentv1.Stability { + switch s { + case StabilityStatic: + return contentv1.Stability_STABILITY_STATIC + case StabilityDynamic: + return contentv1.Stability_STABILITY_DYNAMIC + default: + return contentv1.Stability_STABILITY_UNSPECIFIED + } +} + +// stabilityFromProto converts a wire Stability enum value to its domain +// equivalent. +func stabilityFromProto(s contentv1.Stability) Stability { + switch s { + case contentv1.Stability_STABILITY_STATIC: + return StabilityStatic + case contentv1.Stability_STABILITY_DYNAMIC: + return StabilityDynamic + default: + return StabilityUnspecified + } +} + +// contentBlocksToText concatenates a text-only ContentBlock chain's text +// into a single string, per data-types.md#contextsection's "text-only in +// v1" constraint. Returns ErrNonTextContent if any block isn't a text +// block. +func contentBlocksToText(blocks []*contentv1.ContentBlock) (string, error) { + var sb strings.Builder + for i, b := range blocks { + text := b.GetText() + if text == nil { + return "", fmt.Errorf("context: block %d: %w", i, ErrNonTextContent) + } + sb.WriteString(text.GetText()) + } + return sb.String(), nil +} + +// textToContentBlocks wraps text into the single-element text-only +// ContentBlock slice the wire Section.Content field expects. Empty +// text produces a nil (empty) slice rather than a zero-length text block. +func textToContentBlocks(text string) []*contentv1.ContentBlock { + if text == "" { + return nil + } + return []*contentv1.ContentBlock{ + {Block: &contentv1.ContentBlock_Text{Text: &contentv1.TextBlock{Text: text}}}, + } +} + +// sectionToProto converts a domain Section to its wire +// representation. Returns nil for a nil input. +func sectionToProto(s *Section) *contentv1.ContextSection { + if s == nil { + return nil + } + return &contentv1.ContextSection{ + Provider: s.Provider, + Label: s.Label, + Content: textToContentBlocks(s.Content), + Tokens: s.Tokens, + Stability: stabilityToProto(s.Stability), + Truncated: s.Truncated, + } +} + +// sectionFromProto converts a wire Section to its domain +// representation, rejecting a non-text content block per +// data-types.md#contextsection. Returns nil, nil for a nil input. +func sectionFromProto(s *contentv1.ContextSection) (*Section, error) { + if s == nil { + return nil, nil + } + text, err := contentBlocksToText(s.GetContent()) + if err != nil { + return nil, fmt.Errorf("context: section %q: %w", s.GetProvider(), err) + } + return &Section{ + Provider: s.GetProvider(), + Label: s.GetLabel(), + Content: text, + Tokens: s.GetTokens(), + Stability: stabilityFromProto(s.GetStability()), + Truncated: s.GetTruncated(), + }, nil +} + +// sectionsToProto converts a domain Section chain to its wire +// representation, in order. +func sectionsToProto(sections []*Section) []*contentv1.ContextSection { + if sections == nil { + return nil + } + out := make([]*contentv1.ContextSection, len(sections)) + for i, s := range sections { + out[i] = sectionToProto(s) + } + return out +} + +// sectionsFromProto converts a wire Section chain to its domain +// representation, in order, propagating the first ErrNonTextContent +// found. +func sectionsFromProto(sections []*contentv1.ContextSection) ([]*Section, error) { + if sections == nil { + return nil, nil + } + out := make([]*Section, len(sections)) + for i, s := range sections { + converted, err := sectionFromProto(s) + if err != nil { + return nil, err + } + out[i] = converted + } + return out, nil +} + +// capabilitiesToProto converts a domain Capabilities to its wire +// representation. +func capabilitiesToProto(c *Capabilities) *contextv1.ContextCapabilities { + if c == nil { + return nil + } + return &contextv1.ContextCapabilities{ + DefaultTokenBudget: c.DefaultTokenBudget, + Stability: stabilityToProto(c.Stability), + Compactor: c.Compactor, + SlashCommands: c.SlashCommands, + ConfigSchema: c.ConfigSchema, + SupportedHookPoints: c.SupportedHookPoints, + } +} + +// capabilitiesFromProto converts a wire Capabilities to its domain +// representation. +func capabilitiesFromProto(c *contextv1.ContextCapabilities) *Capabilities { + if c == nil { + return nil + } + return &Capabilities{ + DefaultTokenBudget: c.GetDefaultTokenBudget(), + Stability: stabilityFromProto(c.GetStability()), + Compactor: c.GetCompactor(), + SlashCommands: c.GetSlashCommands(), + ConfigSchema: c.GetConfigSchema(), + SupportedHookPoints: c.GetSupportedHookPoints(), + } +} + +// requestFromProto converts a wire Request to its domain +// representation. CountTokens is left nil — Service.Contribute sets it +// once it has dialed the kernel callback client. +func requestFromProto(r *contextv1.ContextRequest) (*Request, error) { + if r == nil { + return nil, nil + } + prior, err := sectionsFromProto(r.GetPriorSections()) + if err != nil { + return nil, err + } + return &Request{ + SessionID: r.GetSessionId(), + ParentSessionID: r.GetParentSessionId(), + TurnID: r.GetTurnId(), + TokenBudget: r.GetTokenBudget(), + ModelTarget: r.GetModelTarget(), + FilesTouched: r.GetFilesTouched(), + WorkingDirectory: r.GetWorkingDirectory(), + PriorSections: prior, + ConversationHistory: r.GetConversationHistory(), + HistoryTokens: r.GetHistoryTokens(), + AssembledTokensLastTurn: r.GetAssembledTokensLastTurn(), + }, nil +} + +// requestToProto converts a domain Request to its wire +// representation. Provided for symmetry and round-trip testing; server.go +// itself only ever needs requestFromProto since Request arrives +// off the wire, never leaves via it. +func requestToProto(r *Request) *contextv1.ContextRequest { + if r == nil { + return nil + } + return &contextv1.ContextRequest{ + SessionId: r.SessionID, + ParentSessionId: r.ParentSessionID, + TurnId: r.TurnID, + TokenBudget: r.TokenBudget, + ModelTarget: r.ModelTarget, + FilesTouched: r.FilesTouched, + WorkingDirectory: r.WorkingDirectory, + PriorSections: sectionsToProto(r.PriorSections), + ConversationHistory: r.ConversationHistory, + HistoryTokens: r.HistoryTokens, + AssembledTokensLastTurn: r.AssembledTokensLastTurn, + } +} + +// contributionToProto converts a domain Contribution to its wire +// representation. A nil input converts to an empty, non-nil +// *contextv1.ContextContribution so Service.Contribute never returns a +// nil RPC response on a nil Provider.Contribute result. +func contributionToProto(c *Contribution) *contextv1.ContextContribution { + if c == nil { + return &contextv1.ContextContribution{} + } + return &contextv1.ContextContribution{ + Sections: sectionsToProto(c.Sections), + RewrittenHistory: c.RewrittenHistory, + } +} + +// contributionFromProto converts a wire Contribution to its domain +// representation. Provided for symmetry and round-trip testing. +func contributionFromProto(c *contextv1.ContextContribution) (*Contribution, error) { + if c == nil { + return nil, nil + } + sections, err := sectionsFromProto(c.GetSections()) + if err != nil { + return nil, err + } + return &Contribution{ + Sections: sections, + RewrittenHistory: c.GetRewrittenHistory(), + }, nil +} diff --git a/pkg/context/convert_internal_test.go b/pkg/context/convert_internal_test.go new file mode 100644 index 0000000..f072084 --- /dev/null +++ b/pkg/context/convert_internal_test.go @@ -0,0 +1,334 @@ +package context + +import ( + "errors" + "testing" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + contextv1 "github.com/pluggableharness/agent/pkg/context/proto/v1" + modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" +) + +// This file is a white-box (package context, not context_test) test +// deliberately: convert.go's functions are all unexported translation +// helpers between this package's domain types and the generated +// pkg/context/proto/v1 (plus content/v1) wire types — there is nothing +// for an external caller to reach, so they're tested directly here, +// mirroring pkg/plugin's own *_internal_test.go convention. + +func TestStabilityRoundTrip(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + proto contentv1.Stability + want Stability + }{ + {"static", contentv1.Stability_STABILITY_STATIC, StabilityStatic}, + {"dynamic", contentv1.Stability_STABILITY_DYNAMIC, StabilityDynamic}, + {"unspecified", contentv1.Stability_STABILITY_UNSPECIFIED, StabilityUnspecified}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := stabilityFromProto(tt.proto); got != tt.want { + t.Errorf("stabilityFromProto(%v) = %v, want %v", tt.proto, got, tt.want) + } + if got := stabilityToProto(tt.want); got != tt.proto { + t.Errorf("stabilityToProto(%v) = %v, want %v", tt.want, got, tt.proto) + } + }) + } +} + +func TestContentBlocksToText(t *testing.T) { + t.Parallel() + + t.Run("text-only concatenates", func(t *testing.T) { + t.Parallel() + + blocks := []*contentv1.ContentBlock{ + {Block: &contentv1.ContentBlock_Text{Text: &contentv1.TextBlock{Text: "hello "}}}, + {Block: &contentv1.ContentBlock_Text{Text: &contentv1.TextBlock{Text: "world"}}}, + } + got, err := contentBlocksToText(blocks) + if err != nil { + t.Fatalf("contentBlocksToText() error = %v, want nil", err) + } + if want := "hello world"; got != want { + t.Errorf("contentBlocksToText() = %q, want %q", got, want) + } + }) + + t.Run("empty", func(t *testing.T) { + t.Parallel() + + got, err := contentBlocksToText(nil) + if err != nil { + t.Fatalf("contentBlocksToText(nil) error = %v, want nil", err) + } + if got != "" { + t.Errorf("contentBlocksToText(nil) = %q, want empty", got) + } + }) + + t.Run("non-text block rejected", func(t *testing.T) { + t.Parallel() + + blocks := []*contentv1.ContentBlock{ + {Block: &contentv1.ContentBlock_Image{Image: &contentv1.ImageBlock{}}}, + } + _, err := contentBlocksToText(blocks) + if !errors.Is(err, ErrNonTextContent) { + t.Fatalf("contentBlocksToText() error = %v, want wrapping ErrNonTextContent", err) + } + }) +} + +func TestTextToContentBlocks(t *testing.T) { + t.Parallel() + + if got := textToContentBlocks(""); got != nil { + t.Errorf("textToContentBlocks(\"\") = %v, want nil", got) + } + + got := textToContentBlocks("hi") + if len(got) != 1 { + t.Fatalf("textToContentBlocks(\"hi\") len = %d, want 1", len(got)) + } + if got[0].GetText().GetText() != "hi" { + t.Errorf("textToContentBlocks(\"hi\")[0].Text = %q, want %q", got[0].GetText().GetText(), "hi") + } +} + +func TestSectionRoundTrip(t *testing.T) { + t.Parallel() + + if got := sectionToProto(nil); got != nil { + t.Errorf("sectionToProto(nil) = %v, want nil", got) + } + got, err := sectionFromProto(nil) + if err != nil || got != nil { + t.Errorf("sectionFromProto(nil) = %v, %v, want nil, nil", got, err) + } + + section := &Section{ + Provider: "claude-md", + Label: "Project conventions (CLAUDE.md)", + Content: "This repo uses...", + Tokens: 480, + Stability: StabilityStatic, + Truncated: false, + } + proto := sectionToProto(section) + if proto.GetProvider() != section.Provider || proto.GetLabel() != section.Label || proto.GetTokens() != section.Tokens { + t.Errorf("sectionToProto(%+v) = %+v, fields mismatch", section, proto) + } + if len(proto.GetContent()) != 1 || proto.GetContent()[0].GetText().GetText() != section.Content { + t.Errorf("sectionToProto(%+v).Content = %v, want single text block %q", section, proto.GetContent(), section.Content) + } + + back, err := sectionFromProto(proto) + if err != nil { + t.Fatalf("sectionFromProto() error = %v, want nil", err) + } + if !sectionEqual(back, section) { + t.Errorf("sectionFromProto(sectionToProto(%+v)) = %+v, want round trip", section, back) + } +} + +func TestSectionFromProto_nonTextRejected(t *testing.T) { + t.Parallel() + + proto := &contentv1.ContextSection{ + Provider: "bad", + Label: "bad", + Content: []*contentv1.ContentBlock{{Block: &contentv1.ContentBlock_Image{Image: &contentv1.ImageBlock{}}}}, + } + _, err := sectionFromProto(proto) + if !errors.Is(err, ErrNonTextContent) { + t.Fatalf("sectionFromProto() error = %v, want wrapping ErrNonTextContent", err) + } +} + +func TestSectionsRoundTrip(t *testing.T) { + t.Parallel() + + if got := sectionsToProto(nil); got != nil { + t.Errorf("sectionsToProto(nil) = %v, want nil", got) + } + got, err := sectionsFromProto(nil) + if err != nil || got != nil { + t.Errorf("sectionsFromProto(nil) = %v, %v, want nil, nil", got, err) + } + + sections := []*Section{ + {Provider: "a", Label: "A", Content: "one", Tokens: 1, Stability: StabilityStatic}, + {Provider: "b", Label: "B", Content: "two", Tokens: 2, Stability: StabilityDynamic}, + } + proto := sectionsToProto(sections) + if len(proto) != 2 { + t.Fatalf("sectionsToProto() len = %d, want 2", len(proto)) + } + back, err := sectionsFromProto(proto) + if err != nil { + t.Fatalf("sectionsFromProto() error = %v, want nil", err) + } + if len(back) != 2 || !sectionEqual(back[0], sections[0]) || !sectionEqual(back[1], sections[1]) { + t.Errorf("sectionsFromProto(sectionsToProto(%+v)) = %+v, want round trip", sections, back) + } +} + +func TestSectionsFromProto_propagatesError(t *testing.T) { + t.Parallel() + + proto := []*contentv1.ContextSection{ + {Provider: "bad", Content: []*contentv1.ContentBlock{{Block: &contentv1.ContentBlock_Image{Image: &contentv1.ImageBlock{}}}}}, + } + _, err := sectionsFromProto(proto) + if !errors.Is(err, ErrNonTextContent) { + t.Fatalf("sectionsFromProto() error = %v, want wrapping ErrNonTextContent", err) + } +} + +func TestCapabilitiesRoundTrip(t *testing.T) { + t.Parallel() + + if got := capabilitiesToProto(nil); got != nil { + t.Errorf("capabilitiesToProto(nil) = %v, want nil", got) + } + if got := capabilitiesFromProto(nil); got != nil { + t.Errorf("capabilitiesFromProto(nil) = %v, want nil", got) + } + + schema, err := configSchemaForTest() + if err != nil { + t.Fatalf("configSchemaForTest() error = %v", err) + } + caps := &Capabilities{ + DefaultTokenBudget: 2000, + Stability: StabilityStatic, + Compactor: true, + SlashCommands: []*commonv1.PromptExpansionSpec{{Name: "review", Template: "review {{.arg}}"}}, + ConfigSchema: schema, + SupportedHookPoints: []commonv1.HookPoint{commonv1.HookPoint_HOOK_POINT_SESSION_START}, + } + proto := capabilitiesToProto(caps) + if proto.GetDefaultTokenBudget() != caps.DefaultTokenBudget || !proto.GetCompactor() { + t.Errorf("capabilitiesToProto(%+v) = %+v, fields mismatch", caps, proto) + } + back := capabilitiesFromProto(proto) + if back.DefaultTokenBudget != caps.DefaultTokenBudget || + back.Stability != caps.Stability || + back.Compactor != caps.Compactor || + len(back.SlashCommands) != len(caps.SlashCommands) || + len(back.SupportedHookPoints) != len(caps.SupportedHookPoints) { + t.Errorf("capabilitiesFromProto(capabilitiesToProto(%+v)) = %+v, want round trip", caps, back) + } +} + +func TestRequestRoundTrip(t *testing.T) { + t.Parallel() + + if got, err := requestFromProto(nil); got != nil || err != nil { + t.Errorf("requestFromProto(nil) = %v, %v, want nil, nil", got, err) + } + if got := requestToProto(nil); got != nil { + t.Errorf("requestToProto(nil) = %v, want nil", got) + } + + req := &Request{ + SessionID: "sess_01", + ParentSessionID: "sess_00", + TurnID: "turn_01", + TokenBudget: 2000, + ModelTarget: &modelv1.ModelTarget{Id: "claude-opus-5", ContextWindow: 200000, EffectiveCeiling: 176000}, + FilesTouched: []string{"src/auth/validator.py"}, + WorkingDirectory: "/repo", + PriorSections: []*Section{ + {Provider: "claude-md", Label: "CLAUDE.md", Content: "conventions", Tokens: 10, Stability: StabilityStatic}, + }, + HistoryTokens: 500, + AssembledTokensLastTurn: 1200, + } + proto := requestToProto(req) + if proto.GetSessionId() != req.SessionID || proto.GetTurnId() != req.TurnID || proto.GetTokenBudget() != req.TokenBudget { + t.Errorf("requestToProto(%+v) = %+v, fields mismatch", req, proto) + } + + back, err := requestFromProto(proto) + if err != nil { + t.Fatalf("requestFromProto() error = %v, want nil", err) + } + if back.SessionID != req.SessionID || back.TurnID != req.TurnID || back.TokenBudget != req.TokenBudget || + back.HistoryTokens != req.HistoryTokens || back.AssembledTokensLastTurn != req.AssembledTokensLastTurn || + len(back.PriorSections) != len(req.PriorSections) || len(back.FilesTouched) != len(req.FilesTouched) { + t.Errorf("requestFromProto(requestToProto(%+v)) = %+v, want round trip", req, back) + } +} + +func TestRequestFromProto_propagatesSectionError(t *testing.T) { + t.Parallel() + + proto := &contextv1.ContextRequest{ + PriorSections: []*contentv1.ContextSection{ + {Provider: "bad", Content: []*contentv1.ContentBlock{{Block: &contentv1.ContentBlock_Image{Image: &contentv1.ImageBlock{}}}}}, + }, + } + _, err := requestFromProto(proto) + if !errors.Is(err, ErrNonTextContent) { + t.Fatalf("requestFromProto() error = %v, want wrapping ErrNonTextContent", err) + } +} + +func TestContributionRoundTrip(t *testing.T) { + t.Parallel() + + if got := contributionToProto(nil); got == nil { + t.Errorf("contributionToProto(nil) = nil, want non-nil empty contribution") + } + if got, err := contributionFromProto(nil); got != nil || err != nil { + t.Errorf("contributionFromProto(nil) = %v, %v, want nil, nil", got, err) + } + + contribution := &Contribution{ + Sections: []*Section{ + {Provider: "claude-md", Label: "CLAUDE.md", Content: "conventions", Tokens: 10, Stability: StabilityStatic}, + }, + } + proto := contributionToProto(contribution) + if len(proto.GetSections()) != 1 { + t.Fatalf("contributionToProto() sections len = %d, want 1", len(proto.GetSections())) + } + back, err := contributionFromProto(proto) + if err != nil { + t.Fatalf("contributionFromProto() error = %v, want nil", err) + } + if len(back.Sections) != 1 || !sectionEqual(back.Sections[0], contribution.Sections[0]) { + t.Errorf("contributionFromProto(contributionToProto(%+v)) = %+v, want round trip", contribution, back) + } +} + +func TestContributionFromProto_propagatesSectionError(t *testing.T) { + t.Parallel() + + proto := &contextv1.ContextContribution{ + Sections: []*contentv1.ContextSection{ + {Provider: "bad", Content: []*contentv1.ContentBlock{{Block: &contentv1.ContentBlock_Image{Image: &contentv1.ImageBlock{}}}}}, + }, + } + _, err := contributionFromProto(proto) + if !errors.Is(err, ErrNonTextContent) { + t.Fatalf("contributionFromProto() error = %v, want wrapping ErrNonTextContent", err) + } +} + +// configSchemaForTest builds a minimal, valid *configv1.ConfigSchema for +// tests that need a non-nil ConfigSchema value without depending on +// pkg/config from within a white-box test file. +func configSchemaForTest() (*configv1.ConfigSchema, error) { + return &configv1.ConfigSchema{}, nil +} diff --git a/pkg/context/doc.go b/pkg/context/doc.go new file mode 100644 index 0000000..57b330f --- /dev/null +++ b/pkg/context/doc.go @@ -0,0 +1,62 @@ +// Package context is the plugin-author-facing Go SDK for the context +// provider category described in +// docs/specifications/context/README.md, +// docs/specifications/context/protocol.md, +// docs/specifications/context/data-types.md, and +// docs/specifications/context/conformance.md. +// +// A context provider plugin hooks context-assemble and contributes text +// content to the prompt before each model call — a CLAUDE.md reader, an +// AGENTS.md reader, a git-status/file-tree summarizer, or a compactor that +// rewrites the assembled chain and/or the conversation history under +// budget pressure (docs/specifications/context/protocol.md#session-wide-conversation-compaction). +// This package gives a plugin author an idiomatic Provider interface to +// implement (context.go) plus the wiring that turns it into a real +// pluggableharness.context.v1.ContextService server (server.go): schema +// validation, error-taxonomy mapping to gRPC status codes (errors.go), +// domain/proto conversion (convert.go), and a GetCapabilities builder +// (capabilities.go). +// +// # Domain types vs. generated types +// +// [Capabilities], [Section], [Request], and +// [Contribution] are this package's own Go types, not the +// generated pkg/context/proto/v1 messages — convert.go translates between +// them at the server.go boundary. This is a deliberate departure from +// go-layout.md's general "exactly one Go representation of each wire +// message" rule for kernel-side client stubs: the value here is real, not +// cosmetic. [Section.Content] collapses the wire's +// []ContentBlock into a plain string, which is what "text-only in v1" +// (data-types.md#contextsection) actually means for an author — there is +// no way to accidentally construct a multi-block or non-text section +// through this type. Sub-messages that already carry their own SDK +// ownership — model.v1.ModelTarget, config.v1.ConfigSchema, +// common.v1.PromptExpansionSpec, common.v1.HookPoint, +// content.v1.Message — are passed through unwrapped; duplicating those +// here would just be a second, driftable copy of another package's type. +// +// # Import alias +// +// This package's own name, "context", collides with the standard +// library's "context" package that every Provider method and RPC handler +// also needs for its ctx context.Context parameter. That is not a +// conflict inside this package's own files — a package's declarations are +// unqualified within itself, so context.go freely imports stdlib +// "context" for context.Context. A CONSUMER that needs both packages in +// the same file MUST alias one of the two imports, e.g.: +// +// import ( +// "context" +// +// pluggablecontext "github.com/pluggableharness/agent/pkg/context" +// ) +// +// This package's type names ([Capabilities], [Request], +// [Section], [Contribution], [Error]) intentionally +// mirror the wire message names in pkg/context/proto/v1 and the spec text +// verbatim, at the cost of the usual no-package-stutter convention — a +// reader moving between this SDK, the generated stubs, and +// docs/specifications/context/ sees one consistent vocabulary throughout, +// which matters more here than avoiding "context.Request" reading +// redundant under an aliased import. +package context diff --git a/pkg/context/errors.go b/pkg/context/errors.go new file mode 100644 index 0000000..13ef1eb --- /dev/null +++ b/pkg/context/errors.go @@ -0,0 +1,131 @@ +package context + +import ( + "context" + "errors" + "strconv" + + "google.golang.org/grpc/codes" + + "github.com/pluggableharness/agent/pkg/plugin" +) + +// ErrorCategory classifies a context provider's failures +// (conformance.md#error-taxonomy). A plugin MUST classify every failure +// into one of these rather than collapsing them into one generic error. +type ErrorCategory int32 + +const ( + // ErrorCategoryUnspecified is the zero value. Never valid on an + // error a provider actually returns. + ErrorCategoryUnspecified ErrorCategory = iota + // ErrorCategorySourceUnavailable means a declared file/glob/source was + // unreadable at call time. Kernel reaction: drop the section for + // this turn, log; do not fail the turn. + ErrorCategorySourceUnavailable + // ErrorCategoryBudgetExceeded means this provider's own section (or, for + // a compactor, its whole returned chain) exceeds token_budget. + // Kernel reaction: reject the section; do not fail the turn for a + // non-compactor violator. + ErrorCategoryBudgetExceeded + // ErrorCategoryScopeViolation means a non-compactor provider mutated a + // section it doesn't own. Kernel reaction: discard the entire + // response, restore the prior chain, log. + ErrorCategoryScopeViolation + // ErrorCategoryInvalidRequest means a malformed request — a kernel/adapter + // bug. MUST NOT be retried as-is. + ErrorCategoryInvalidRequest + // ErrorCategoryUnknown covers anything else. The message MUST include the + // raw plugin error message for debugging. + ErrorCategoryUnknown +) + +// reason returns the spec's own lowercase-snake vocabulary name for c +// (conformance.md#error-taxonomy's table), used as the structured +// google.rpc.ErrorInfo.Reason plugin.StatusError attaches. +func (c ErrorCategory) reason() string { + switch c { + case ErrorCategorySourceUnavailable: + return "source_unavailable" + case ErrorCategoryBudgetExceeded: + return "budget_exceeded" + case ErrorCategoryScopeViolation: + return "scope_violation" + case ErrorCategoryInvalidRequest: + return "invalid_request" + case ErrorCategoryUnknown: + return "unknown" + default: + return "unknown" + } +} + +// code returns the canonical grpc/codes.Code c maps to +// (conformance.md#error-taxonomy's wire mapping table). unknown maps to +// codes.Internal, never codes.Unknown. +func (c ErrorCategory) code() codes.Code { + switch c { + case ErrorCategorySourceUnavailable: + return codes.Unavailable + case ErrorCategoryBudgetExceeded: + return codes.FailedPrecondition + case ErrorCategoryScopeViolation: + return codes.PermissionDenied + case ErrorCategoryInvalidRequest: + return codes.InvalidArgument + default: + return codes.Internal + } +} + +// Error is the structured error a Provider method returns to +// classify a failure per conformance.md#error-taxonomy. Category and +// Message MUST be set; Retryable MUST be an honest signal — the kernel +// may use it to decide whether to retry the call that produced this +// error. +type Error struct { + Category ErrorCategory + Message string + Retryable bool +} + +// Error implements the error interface. +func (e *Error) Error() string { + return e.Message +} + +// errorDomain is the google.rpc.ErrorInfo.Domain every Error this +// package translates carries, per .claude/rules/grpc.md's "domain is the +// calling category's own error-taxonomy name" convention. +const errorDomain = "context.pluggableharness.dev" + +// toStatusError converts an error returned from a Provider method into a +// gRPC status error suitable for an RPC handler to return. Cancellation +// (context.Canceled / context.DeadlineExceeded) is normal control flow, +// never an application error (.claude/rules/grpc.md), and is mapped to +// its matching gRPC code directly rather than through the Error +// taxonomy. A *Error is mapped via its own Category; any other +// error is treated as ErrorCategoryUnknown (codes.Internal, never +// codes.Unknown), with the original error's message preserved for +// debugging per conformance.md's "unknown" row. +func toStatusError(err error) error { + if err == nil { + return nil + } + + switch { + case errors.Is(err, context.Canceled): + return plugin.StatusError(codes.Canceled, errorDomain, "canceled", err.Error(), nil) + case errors.Is(err, context.DeadlineExceeded): + return plugin.StatusError(codes.DeadlineExceeded, errorDomain, "deadline_exceeded", err.Error(), nil) + } + + var ctxErr *Error + if errors.As(err, &ctxErr) { + return plugin.StatusError(ctxErr.Category.code(), errorDomain, ctxErr.Category.reason(), ctxErr.Message, map[string]string{ + "retryable": strconv.FormatBool(ctxErr.Retryable), + }) + } + + return plugin.StatusError(ErrorCategoryUnknown.code(), errorDomain, ErrorCategoryUnknown.reason(), err.Error(), nil) +} diff --git a/pkg/context/errors_internal_test.go b/pkg/context/errors_internal_test.go new file mode 100644 index 0000000..49effa6 --- /dev/null +++ b/pkg/context/errors_internal_test.go @@ -0,0 +1,122 @@ +package context + +import ( + "context" + "errors" + "testing" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// This file is a white-box (package context, not context_test) test: +// toStatusError and ErrorCategory's code()/reason() methods are +// unexported translation logic, mirroring convert_internal_test.go's +// rationale. + +func TestToStatusError_nil(t *testing.T) { + t.Parallel() + + if err := toStatusError(nil); err != nil { + t.Errorf("toStatusError(nil) = %v, want nil", err) + } +} + +func TestToStatusError_categoryMapping(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + category ErrorCategory + wantCode codes.Code + wantReason string + }{ + {"source_unavailable", ErrorCategorySourceUnavailable, codes.Unavailable, "source_unavailable"}, + {"budget_exceeded", ErrorCategoryBudgetExceeded, codes.FailedPrecondition, "budget_exceeded"}, + {"scope_violation", ErrorCategoryScopeViolation, codes.PermissionDenied, "scope_violation"}, + {"invalid_request", ErrorCategoryInvalidRequest, codes.InvalidArgument, "invalid_request"}, + {"unknown", ErrorCategoryUnknown, codes.Internal, "unknown"}, + {"unspecified falls back to internal/unknown", ErrorCategoryUnspecified, codes.Internal, "unknown"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctxErr := &Error{Category: tt.category, Message: "boom", Retryable: true} + wrapped := toStatusError(ctxErr) + st, ok := status.FromError(wrapped) + if !ok { + t.Fatalf("status.FromError(%v) ok = false, want true", wrapped) + } + if st.Code() != tt.wantCode { + t.Errorf("Code() = %v, want %v", st.Code(), tt.wantCode) + } + if st.Message() != "boom" { + t.Errorf("Message() = %q, want %q", st.Message(), "boom") + } + + var info *errdetails.ErrorInfo + for _, d := range st.Details() { + if e, ok := d.(*errdetails.ErrorInfo); ok { + info = e + break + } + } + if info == nil { + t.Fatalf("Details() contains no *errdetails.ErrorInfo, got %v", st.Details()) + } + if info.GetReason() != tt.wantReason { + t.Errorf("ErrorInfo.Reason = %q, want %q", info.GetReason(), tt.wantReason) + } + if info.GetDomain() != errorDomain { + t.Errorf("ErrorInfo.Domain = %q, want %q", info.GetDomain(), errorDomain) + } + if got := info.GetMetadata()["retryable"]; got != "true" { + t.Errorf("ErrorInfo.Metadata[retryable] = %q, want %q", got, "true") + } + }) + } +} + +func TestToStatusError_cancellation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + wantCode codes.Code + }{ + {"canceled", context.Canceled, codes.Canceled}, + {"deadline exceeded", context.DeadlineExceeded, codes.DeadlineExceeded}, + {"wrapped canceled", errors.New("op: " + context.Canceled.Error()), codes.Internal}, // not errors.Is-detectable; falls through to unknown + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + st, ok := status.FromError(toStatusError(tt.err)) + if !ok { + t.Fatalf("status.FromError() ok = false, want true") + } + if st.Code() != tt.wantCode { + t.Errorf("Code() = %v, want %v", st.Code(), tt.wantCode) + } + }) + } +} + +func TestToStatusError_genericError(t *testing.T) { + t.Parallel() + + st, ok := status.FromError(toStatusError(errors.New("plain failure"))) + if !ok { + t.Fatalf("status.FromError() ok = false, want true") + } + if st.Code() != codes.Internal { + t.Errorf("Code() = %v, want %v", st.Code(), codes.Internal) + } + if st.Message() != "plain failure" { + t.Errorf("Message() = %q, want %q", st.Message(), "plain failure") + } +} diff --git a/pkg/context/errors_test.go b/pkg/context/errors_test.go new file mode 100644 index 0000000..ef96406 --- /dev/null +++ b/pkg/context/errors_test.go @@ -0,0 +1,19 @@ +package context_test + +import ( + "testing" + + pluggablecontext "github.com/pluggableharness/agent/pkg/context" +) + +func TestContextError_Error(t *testing.T) { + t.Parallel() + + err := &pluggablecontext.Error{ + Category: pluggablecontext.ErrorCategorySourceUnavailable, + Message: "CLAUDE.md deleted mid-session", + } + if got, want := err.Error(), "CLAUDE.md deleted mid-session"; got != want { + t.Errorf("Error() = %q, want %q", got, want) + } +} diff --git a/pkg/context/helpers_internal_test.go b/pkg/context/helpers_internal_test.go new file mode 100644 index 0000000..abf6947 --- /dev/null +++ b/pkg/context/helpers_internal_test.go @@ -0,0 +1,57 @@ +package context + +import ( + "context" + "net" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + "github.com/pluggableharness/agent/pkg/kernel" + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" +) + +// fakeKernelServer is a hand-written kernelv1.KernelCallbackServiceServer +// fake (go-testing.md: fakes, not mocking frameworks), covering only the +// one RPC this package's SDK calls: CountTokens. +type fakeKernelServer struct { + kernelv1.UnimplementedKernelCallbackServiceServer + + countTokensFunc func(*kernelv1.CountTokensRequest) (*kernelv1.CountTokensResult, error) +} + +func (f *fakeKernelServer) CountTokens(ctx context.Context, req *kernelv1.CountTokensRequest) (*kernelv1.CountTokensResult, error) { + if f.countTokensFunc != nil { + return f.countTokensFunc(req) + } + return f.UnimplementedKernelCallbackServiceServer.CountTokens(ctx, req) +} + +// newTestKernelClient starts srv on an in-memory bufconn listener and +// returns a *kernel.Client dialed against it — a real gRPC round trip, so +// tests exercising countTokens/CountTokens prove they actually call +// through the kernel callback channel rather than estimating locally. +// Mirrors pkg/kernel/helpers_test.go's newTestClient (unexported there, +// so not importable directly from this package's tests). +func newTestKernelClient(t *testing.T, srv kernelv1.KernelCallbackServiceServer) *kernel.Client { + t.Helper() + + const bufSize = 1 << 20 + lis := bufconn.Listen(bufSize) + + gs := grpc.NewServer() + kernelv1.RegisterKernelCallbackServiceServer(gs, srv) + go func() { _ = gs.Serve(lis) }() + t.Cleanup(gs.Stop) + + dialer := func(context.Context, string) (net.Conn, error) { return lis.Dial() } + conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(dialer), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("grpc.NewClient: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + return kernel.NewClient(conn) +} diff --git a/pkg/context/proto/v1/context.pb.go b/pkg/context/proto/v1/context.pb.go deleted file mode 100644 index 85ec6d6..0000000 --- a/pkg/context/proto/v1/context.pb.go +++ /dev/null @@ -1,1043 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: pluggableharness/context/v1/context.proto - -// Package pluggableharness.context.v1 defines the context provider plugin protocol -// described in specifications/context.md — plugins that hook -// context-assemble and contribute content to the prompt before each model -// call (e.g. a CLAUDE.md reader, an AGENTS.md reader, a git-status/file-tree -// summarizer). See .claude/rules/proto.md. -// -// ContextSection and Stability — the section chain this protocol assembles -// and the turn-to-turn-change hint each section carries — are defined in -// pluggableharness.content.v1, not here: that chain is consumed by -// both this protocol and the model provider's completion request -// (forthcoming), so it's homed alongside content.v1's other shared content -// shapes rather than duplicated or owned by only one consumer. - -package contextv1 - -import ( - v13 "github.com/pluggableharness/agent/pkg/common/proto/v1" - v12 "github.com/pluggableharness/agent/pkg/config/proto/v1" - v1 "github.com/pluggableharness/agent/pkg/content/proto/v1" - v14 "github.com/pluggableharness/agent/pkg/model/proto/v1" - v15 "github.com/pluggableharness/agent/pkg/render/proto/v1" - v11 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - structpb "google.golang.org/protobuf/types/known/structpb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// ContextErrorCategory classifies a context provider's failures. -// context.md §10 — smaller than the model-provider taxonomy -// (model.md §8), but a plugin MUST still classify failures rather than -// collapsing them into one generic error. -type ContextErrorCategory int32 - -const ( - // Zero value. Never valid for a real error; its presence on the wire - // means a caller forgot to set the field. - ContextErrorCategory_CONTEXT_ERROR_CATEGORY_UNSPECIFIED ContextErrorCategory = 0 - // A declared file/glob/source was unreadable at call time (deleted - // mid-session, permission error). The kernel's expected reaction: drop - // the section for this turn, log; do not fail the turn. - ContextErrorCategory_CONTEXT_ERROR_CATEGORY_SOURCE_UNAVAILABLE ContextErrorCategory = 1 - // The provider's own section (or, for a compactor, its whole returned - // chain) exceeds token_budget. The kernel's expected reaction: reject - // per context.md §6; do not fail the turn for a non-compactor violator. - ContextErrorCategory_CONTEXT_ERROR_CATEGORY_BUDGET_EXCEEDED ContextErrorCategory = 2 - // A non-compactor provider mutated a section it doesn't own (context.md - // §5). The kernel's expected reaction: discard the entire response, - // restore the prior chain, log the violation. - ContextErrorCategory_CONTEXT_ERROR_CATEGORY_SCOPE_VIOLATION ContextErrorCategory = 3 - // A malformed request — a kernel/adapter bug. MUST NOT be retried as-is; - // MUST be logged with the full request shape. - ContextErrorCategory_CONTEXT_ERROR_CATEGORY_INVALID_REQUEST ContextErrorCategory = 4 - // Any other failure. MUST include the raw plugin error message for - // debugging. - ContextErrorCategory_CONTEXT_ERROR_CATEGORY_UNKNOWN ContextErrorCategory = 5 -) - -// Enum value maps for ContextErrorCategory. -var ( - ContextErrorCategory_name = map[int32]string{ - 0: "CONTEXT_ERROR_CATEGORY_UNSPECIFIED", - 1: "CONTEXT_ERROR_CATEGORY_SOURCE_UNAVAILABLE", - 2: "CONTEXT_ERROR_CATEGORY_BUDGET_EXCEEDED", - 3: "CONTEXT_ERROR_CATEGORY_SCOPE_VIOLATION", - 4: "CONTEXT_ERROR_CATEGORY_INVALID_REQUEST", - 5: "CONTEXT_ERROR_CATEGORY_UNKNOWN", - } - ContextErrorCategory_value = map[string]int32{ - "CONTEXT_ERROR_CATEGORY_UNSPECIFIED": 0, - "CONTEXT_ERROR_CATEGORY_SOURCE_UNAVAILABLE": 1, - "CONTEXT_ERROR_CATEGORY_BUDGET_EXCEEDED": 2, - "CONTEXT_ERROR_CATEGORY_SCOPE_VIOLATION": 3, - "CONTEXT_ERROR_CATEGORY_INVALID_REQUEST": 4, - "CONTEXT_ERROR_CATEGORY_UNKNOWN": 5, - } -) - -func (x ContextErrorCategory) Enum() *ContextErrorCategory { - p := new(ContextErrorCategory) - *p = x - return p -} - -func (x ContextErrorCategory) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (ContextErrorCategory) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_context_v1_context_proto_enumTypes[0].Descriptor() -} - -func (ContextErrorCategory) Type() protoreflect.EnumType { - return &file_pluggableharness_context_v1_context_proto_enumTypes[0] -} - -func (x ContextErrorCategory) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use ContextErrorCategory.Descriptor instead. -func (ContextErrorCategory) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_context_v1_context_proto_rawDescGZIP(), []int{0} -} - -// GetCapabilitiesRequest carries no fields — GetCapabilities takes no -// request-scoped parameters. -type GetCapabilitiesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetCapabilitiesRequest) Reset() { - *x = GetCapabilitiesRequest{} - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetCapabilitiesRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetCapabilitiesRequest) ProtoMessage() {} - -func (x *GetCapabilitiesRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[0] - 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 GetCapabilitiesRequest.ProtoReflect.Descriptor instead. -func (*GetCapabilitiesRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_context_v1_context_proto_rawDescGZIP(), []int{0} -} - -// GetCapabilitiesResponse wraps ContextCapabilities for the RPC signature, -// per this repo's per-RPC envelope convention (.claude/rules/proto.md). -type GetCapabilitiesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Capabilities *ContextCapabilities `protobuf:"bytes,1,opt,name=capabilities,proto3" json:"capabilities,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetCapabilitiesResponse) Reset() { - *x = GetCapabilitiesResponse{} - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetCapabilitiesResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetCapabilitiesResponse) ProtoMessage() {} - -func (x *GetCapabilitiesResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[1] - 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 GetCapabilitiesResponse.ProtoReflect.Descriptor instead. -func (*GetCapabilitiesResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_context_v1_context_proto_rawDescGZIP(), []int{1} -} - -func (x *GetCapabilitiesResponse) GetCapabilities() *ContextCapabilities { - if x != nil { - return x.Capabilities - } - return nil -} - -// ConfigureRequest wraps the provider's agent.hcl config block, already -// decoded via the schema-to-cty bridge, for the Configure RPC. -type ConfigureRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The decoded config object. Field contents are provider-specific — which - // file(s)/globs to read, max-hop @import depth, whether to strip HTML - // comments, etc. (context.md §3). - Config *structpb.Struct `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ConfigureRequest) Reset() { - *x = ConfigureRequest{} - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ConfigureRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConfigureRequest) ProtoMessage() {} - -func (x *ConfigureRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[2] - 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 ConfigureRequest.ProtoReflect.Descriptor instead. -func (*ConfigureRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_context_v1_context_proto_rawDescGZIP(), []int{2} -} - -func (x *ConfigureRequest) GetConfig() *structpb.Struct { - if x != nil { - return x.Config - } - return nil -} - -// ContextCapabilities reports a context provider's static properties. -// context.md §2. stability and compactor MUST be re-queryable cheaply and -// MUST NOT depend on a live read of the content source — a provider reading -// CLAUDE.md declares stability as a fixed property of the plugin, not a -// per-call judgment. -type ContextCapabilities struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The token cap this provider requests if agent.hcl does not override it. - // MUST be set. context.md §2, §6. - DefaultTokenBudget int64 `protobuf:"varint,1,opt,name=default_token_budget,json=defaultTokenBudget,proto3" json:"default_token_budget,omitempty"` - // Whether this provider's contributed content changes turn to turn. MUST - // be set. context.md §2, §7. - Stability v1.Stability `protobuf:"varint,2,opt,name=stability,proto3,enum=pluggableharness.content.v1.Stability" json:"stability,omitempty"` - // Whether this provider acts as a compactor: MAY rewrite, merge, or drop - // other providers' sections in the chain it receives, and MAY receive - // conversation_history on ContextRequest and return rewritten_history. - // MUST be set; defaults to false. context.md §5, §5.1. - Compactor bool `protobuf:"varint,3,opt,name=compactor,proto3" json:"compactor,omitempty"` - // Slash commands this provider contributes. MAY be empty. - // context.md §2, configuration.md §5. - SlashCommands []*v11.SlashCommandSpec `protobuf:"bytes,4,rep,name=slash_commands,json=slashCommands,proto3" json:"slash_commands,omitempty"` - // This provider's agent.hcl config schema, advertised so the kernel knows - // what fields Configure accepts. configuration.md §4. - ConfigSchema *v12.ConfigSchema `protobuf:"bytes,5,opt,name=config_schema,json=configSchema,proto3" json:"config_schema,omitempty"` - // Which hook points (agent-loop/hook-dispatch.md) this provider declares - // HookSubscriberService.DispatchHook subscriptions for, advertised - // up front alongside its other static properties. MAY be empty — a - // context provider with no hook{} blocks in agent.hcl never has - // DispatchHook called on it. The enum itself lives in common.v1, not - // hook.v1 — hook.v1 imports this package's model/tool/plan dependencies, - // so a category capability message importing hook.v1 directly would - // cycle back through it; common.v1 is the shared leaf package instead. - SupportedHookPoints []v13.HookPoint `protobuf:"varint,6,rep,packed,name=supported_hook_points,json=supportedHookPoints,proto3,enum=pluggableharness.common.v1.HookPoint" json:"supported_hook_points,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ContextCapabilities) Reset() { - *x = ContextCapabilities{} - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ContextCapabilities) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ContextCapabilities) ProtoMessage() {} - -func (x *ContextCapabilities) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[3] - 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 ContextCapabilities.ProtoReflect.Descriptor instead. -func (*ContextCapabilities) Descriptor() ([]byte, []int) { - return file_pluggableharness_context_v1_context_proto_rawDescGZIP(), []int{3} -} - -func (x *ContextCapabilities) GetDefaultTokenBudget() int64 { - if x != nil { - return x.DefaultTokenBudget - } - return 0 -} - -func (x *ContextCapabilities) GetStability() v1.Stability { - if x != nil { - return x.Stability - } - return v1.Stability(0) -} - -func (x *ContextCapabilities) GetCompactor() bool { - if x != nil { - return x.Compactor - } - return false -} - -func (x *ContextCapabilities) GetSlashCommands() []*v11.SlashCommandSpec { - if x != nil { - return x.SlashCommands - } - return nil -} - -func (x *ContextCapabilities) GetConfigSchema() *v12.ConfigSchema { - if x != nil { - return x.ConfigSchema - } - return nil -} - -func (x *ContextCapabilities) GetSupportedHookPoints() []v13.HookPoint { - if x != nil { - return x.SupportedHookPoints - } - return nil -} - -// ConfigureResponse is empty on success. A Configure failure (e.g. a -// declared source path/glob resolving to nothing) surfaces as a gRPC status -// carrying a ContextError in its structured detail, per -// .claude/rules/grpc.md — not as an in-band field on this message. -type ConfigureResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ConfigureResponse) Reset() { - *x = ConfigureResponse{} - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ConfigureResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConfigureResponse) ProtoMessage() {} - -func (x *ConfigureResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[4] - 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 ConfigureResponse.ProtoReflect.Descriptor instead. -func (*ConfigureResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_context_v1_context_proto_rawDescGZIP(), []int{4} -} - -// ContextRequest is the context-assemble RPC's request, delivered to -// Contribute once per firing. context.md §4. -type ContextRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The current session's identifier. - SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - // The parent session's identifier, when this session is a sub-agent - // session. Empty for a top-level session. - ParentSessionId string `protobuf:"bytes,2,opt,name=parent_session_id,json=parentSessionId,proto3" json:"parent_session_id,omitempty"` - // Which turn of the session this firing is for. A ULID, standardized - // across the whole protocol (matches plan.v1's turn_id field). Same field - // number as the retired int64 turn-number predecessor field; the rename - // is a sanctioned pre-release wire change, not a v2 bump. - TurnId string `protobuf:"bytes,3,opt,name=turn_id,json=turnId,proto3" json:"turn_id,omitempty"` - // The kernel's computed token allocation for this provider on this call. - // MUST be set. A returned section MUST NOT exceed this. context.md §4, - // §6. - TokenBudget int64 `protobuf:"varint,4,opt,name=token_budget,json=tokenBudget,proto3" json:"token_budget,omitempty"` - // The model this contribution is being assembled for, so the provider can - // tailor content (and compute tokens against the right budget) for the - // model that will actually consume it. MUST be set. context.md §4. - ModelTarget *v14.ModelTarget `protobuf:"bytes,5,opt,name=model_target,json=modelTarget,proto3" json:"model_target,omitempty"` - // Paths touched so far this session, enabling JIT-scoped contributions - // (e.g. a subdirectory-scoped convention-file reader). MAY be empty, e.g. - // at turn 0 / session start. context.md §4, §8. - FilesTouched []string `protobuf:"bytes,6,rep,name=files_touched,json=filesTouched,proto3" json:"files_touched,omitempty"` - // The session's current working directory. - WorkingDirectory string `protobuf:"bytes,7,opt,name=working_directory,json=workingDirectory,proto3" json:"working_directory,omitempty"` - // The accumulated output of earlier providers in this hook's - // declaration-order chain. MUST be set (MAY be empty on the first - // provider in the chain). context.md §4, §5. - PriorSections []*v1.ContextSection `protobuf:"bytes,8,rep,name=prior_sections,json=priorSections,proto3" json:"prior_sections,omitempty"` - // The session's conversation history. Populated ONLY for a provider whose - // ContextCapabilities.compactor == true; a non-compactor provider MUST - // NOT receive this — for those providers this field arrives empty, which - // is indistinguishable from (and semantically equivalent to) "not - // provided". context.md §5.1. - ConversationHistory []*v1.Message `protobuf:"bytes,9,rep,name=conversation_history,json=conversationHistory,proto3" json:"conversation_history,omitempty"` - // The current conversation-history token total, kernel-computed. One of - // the two signals a compactor provider needs to decide WHEN to compact - // without re-counting history itself — see data-types.md's compactor - // workflow discussion in the orbit of §5.1. - HistoryTokens int64 `protobuf:"varint,10,opt,name=history_tokens,json=historyTokens,proto3" json:"history_tokens,omitempty"` - // The total assembled context size of the previous turn (all providers' - // sections combined), kernel-computed. The second compact-timing signal, - // alongside history_tokens above. - AssembledTokensLastTurn int64 `protobuf:"varint,11,opt,name=assembled_tokens_last_turn,json=assembledTokensLastTurn,proto3" json:"assembled_tokens_last_turn,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ContextRequest) Reset() { - *x = ContextRequest{} - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ContextRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ContextRequest) ProtoMessage() {} - -func (x *ContextRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_context_v1_context_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 ContextRequest.ProtoReflect.Descriptor instead. -func (*ContextRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_context_v1_context_proto_rawDescGZIP(), []int{5} -} - -func (x *ContextRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -func (x *ContextRequest) GetParentSessionId() string { - if x != nil { - return x.ParentSessionId - } - return "" -} - -func (x *ContextRequest) GetTurnId() string { - if x != nil { - return x.TurnId - } - return "" -} - -func (x *ContextRequest) GetTokenBudget() int64 { - if x != nil { - return x.TokenBudget - } - return 0 -} - -func (x *ContextRequest) GetModelTarget() *v14.ModelTarget { - if x != nil { - return x.ModelTarget - } - return nil -} - -func (x *ContextRequest) GetFilesTouched() []string { - if x != nil { - return x.FilesTouched - } - return nil -} - -func (x *ContextRequest) GetWorkingDirectory() string { - if x != nil { - return x.WorkingDirectory - } - return "" -} - -func (x *ContextRequest) GetPriorSections() []*v1.ContextSection { - if x != nil { - return x.PriorSections - } - return nil -} - -func (x *ContextRequest) GetConversationHistory() []*v1.Message { - if x != nil { - return x.ConversationHistory - } - return nil -} - -func (x *ContextRequest) GetHistoryTokens() int64 { - if x != nil { - return x.HistoryTokens - } - return 0 -} - -func (x *ContextRequest) GetAssembledTokensLastTurn() int64 { - if x != nil { - return x.AssembledTokensLastTurn - } - return 0 -} - -// ContextContribution is Contribute's response: the full, possibly-modified -// section chain, with this provider's own section appended — never a -// delta. context.md §4. -type ContextContribution struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The full accumulated chain, in declaration order, including this - // provider's own new or updated section(s). - Sections []*v1.ContextSection `protobuf:"bytes,1,rep,name=sections,proto3" json:"sections,omitempty"` - // The session's conversation history, rewritten to replace what was sent - // in ContextRequest.conversation_history. MAY be included by a compactor - // provider (ContextCapabilities.compactor == true) alongside its section - // contribution. When present, the kernel MUST replace the turn's - // conversation history with this before the next model call. context.md - // §5.1. - RewrittenHistory []*v1.Message `protobuf:"bytes,2,rep,name=rewritten_history,json=rewrittenHistory,proto3" json:"rewritten_history,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ContextContribution) Reset() { - *x = ContextContribution{} - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ContextContribution) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ContextContribution) ProtoMessage() {} - -func (x *ContextContribution) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_context_v1_context_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 ContextContribution.ProtoReflect.Descriptor instead. -func (*ContextContribution) Descriptor() ([]byte, []int) { - return file_pluggableharness_context_v1_context_proto_rawDescGZIP(), []int{6} -} - -func (x *ContextContribution) GetSections() []*v1.ContextSection { - if x != nil { - return x.Sections - } - return nil -} - -func (x *ContextContribution) GetRewrittenHistory() []*v1.Message { - if x != nil { - return x.RewrittenHistory - } - return nil -} - -// ContextError is the structured error detail a context provider attaches -// to a failed RPC's gRPC status, per .claude/rules/grpc.md's error-taxonomy -// convention. -type ContextError struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Which category of failure this is. - Category ContextErrorCategory `protobuf:"varint,1,opt,name=category,proto3,enum=pluggableharness.context.v1.ContextErrorCategory" json:"category,omitempty"` - // Human-readable error detail, e.g. the raw plugin error message for - // CONTEXT_ERROR_CATEGORY_UNKNOWN. - Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` - // Whether the kernel may retry the call that produced this error. - Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ContextError) Reset() { - *x = ContextError{} - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ContextError) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ContextError) ProtoMessage() {} - -func (x *ContextError) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[7] - 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 ContextError.ProtoReflect.Descriptor instead. -func (*ContextError) Descriptor() ([]byte, []int) { - return file_pluggableharness_context_v1_context_proto_rawDescGZIP(), []int{7} -} - -func (x *ContextError) GetCategory() ContextErrorCategory { - if x != nil { - return x.Category - } - return ContextErrorCategory_CONTEXT_ERROR_CATEGORY_UNSPECIFIED -} - -func (x *ContextError) GetMessage() string { - if x != nil { - return x.Message - } - return "" -} - -func (x *ContextError) GetRetryable() bool { - if x != nil { - return x.Retryable - } - return false -} - -// RenderRequest carries the opaque payload for the optional Render RPC. -type RenderRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The opaque emitted payload to render — see .claude/rules/grpc.md's - // Emit->Render->Paint carve-out. Never interpreted by the kernel or any - // other plugin; only the producing provider's own Render implementation - // understands its shape. - Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` - // The schema version this payload was emitted against, so a Render - // implementation can detect drift between the version it was built - // against and the version live in a running session. See - // ../frontend/render-tree.md#schema-versioning for the canonical - // definition of this field's semantics (owned by the frontend/widget - // workstream; this field just carries the same value). - SchemaVersion string `protobuf:"bytes,2,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RenderRequest) Reset() { - *x = RenderRequest{} - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RenderRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RenderRequest) ProtoMessage() {} - -func (x *RenderRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[8] - 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 RenderRequest.ProtoReflect.Descriptor instead. -func (*RenderRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_context_v1_context_proto_rawDescGZIP(), []int{8} -} - -func (x *RenderRequest) GetPayload() []byte { - if x != nil { - return x.Payload - } - return nil -} - -func (x *RenderRequest) GetSchemaVersion() string { - if x != nil { - return x.SchemaVersion - } - return "" -} - -// RenderResponse wraps the rendered output of the optional Render RPC. -type RenderResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The rendered tree, per the general Emit->Render->Paint pipeline - // (frontend.md §1). context.md §9. - Tree *v15.RenderTree `protobuf:"bytes,1,opt,name=tree,proto3" json:"tree,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RenderResponse) Reset() { - *x = RenderResponse{} - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RenderResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RenderResponse) ProtoMessage() {} - -func (x *RenderResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[9] - 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 RenderResponse.ProtoReflect.Descriptor instead. -func (*RenderResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_context_v1_context_proto_rawDescGZIP(), []int{9} -} - -func (x *RenderResponse) GetTree() *v15.RenderTree { - if x != nil { - return x.Tree - } - return nil -} - -// DescribeRequest carries no fields — Describe takes no request-scoped -// parameters. -type DescribeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DescribeRequest) Reset() { - *x = DescribeRequest{} - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DescribeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DescribeRequest) ProtoMessage() {} - -func (x *DescribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[10] - 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 DescribeRequest.ProtoReflect.Descriptor instead. -func (*DescribeRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_context_v1_context_proto_rawDescGZIP(), []int{10} -} - -// DescribeResponse reports this plugin build's own identity. See the -// Describe RPC comment on ContextService above. -type DescribeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // This plugin build's identity: name, version, source, category, - // protocol_version. - Producer *v13.ProducerRef `protobuf:"bytes,1,opt,name=producer,proto3" json:"producer,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DescribeResponse) Reset() { - *x = DescribeResponse{} - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DescribeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DescribeResponse) ProtoMessage() {} - -func (x *DescribeResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_context_v1_context_proto_msgTypes[11] - 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 DescribeResponse.ProtoReflect.Descriptor instead. -func (*DescribeResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_context_v1_context_proto_rawDescGZIP(), []int{11} -} - -func (x *DescribeResponse) GetProducer() *v13.ProducerRef { - if x != nil { - return x.Producer - } - return nil -} - -var File_pluggableharness_context_v1_context_proto protoreflect.FileDescriptor - -const file_pluggableharness_context_v1_context_proto_rawDesc = "" + - "\n" + - ")pluggableharness/context/v1/context.proto\x12\x1bpluggableharness.context.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a'pluggableharness/common/v1/common.proto\x1a'pluggableharness/config/v1/config.proto\x1a)pluggableharness/content/v1/content.proto\x1a%pluggableharness/model/v1/model.proto\x1a'pluggableharness/render/v1/render.proto\x1a3pluggableharness/slashcommand/v1/slashcommand.proto\"\x18\n" + - "\x16GetCapabilitiesRequest\"o\n" + - "\x17GetCapabilitiesResponse\x12T\n" + - "\fcapabilities\x18\x01 \x01(\v20.pluggableharness.context.v1.ContextCapabilitiesR\fcapabilities\"C\n" + - "\x10ConfigureRequest\x12/\n" + - "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06config\"\xb0\x03\n" + - "\x13ContextCapabilities\x120\n" + - "\x14default_token_budget\x18\x01 \x01(\x03R\x12defaultTokenBudget\x12D\n" + - "\tstability\x18\x02 \x01(\x0e2&.pluggableharness.content.v1.StabilityR\tstability\x12\x1c\n" + - "\tcompactor\x18\x03 \x01(\bR\tcompactor\x12Y\n" + - "\x0eslash_commands\x18\x04 \x03(\v22.pluggableharness.slashcommand.v1.SlashCommandSpecR\rslashCommands\x12M\n" + - "\rconfig_schema\x18\x05 \x01(\v2(.pluggableharness.config.v1.ConfigSchemaR\fconfigSchema\x12Y\n" + - "\x15supported_hook_points\x18\x06 \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPoints\"\x13\n" + - "\x11ConfigureResponse\"\xc5\x04\n" + - "\x0eContextRequest\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\x12*\n" + - "\x11parent_session_id\x18\x02 \x01(\tR\x0fparentSessionId\x12\x17\n" + - "\aturn_id\x18\x03 \x01(\tR\x06turnId\x12!\n" + - "\ftoken_budget\x18\x04 \x01(\x03R\vtokenBudget\x12I\n" + - "\fmodel_target\x18\x05 \x01(\v2&.pluggableharness.model.v1.ModelTargetR\vmodelTarget\x12#\n" + - "\rfiles_touched\x18\x06 \x03(\tR\ffilesTouched\x12+\n" + - "\x11working_directory\x18\a \x01(\tR\x10workingDirectory\x12R\n" + - "\x0eprior_sections\x18\b \x03(\v2+.pluggableharness.content.v1.ContextSectionR\rpriorSections\x12W\n" + - "\x14conversation_history\x18\t \x03(\v2$.pluggableharness.content.v1.MessageR\x13conversationHistory\x12%\n" + - "\x0ehistory_tokens\x18\n" + - " \x01(\x03R\rhistoryTokens\x12;\n" + - "\x1aassembled_tokens_last_turn\x18\v \x01(\x03R\x17assembledTokensLastTurn\"\xb1\x01\n" + - "\x13ContextContribution\x12G\n" + - "\bsections\x18\x01 \x03(\v2+.pluggableharness.content.v1.ContextSectionR\bsections\x12Q\n" + - "\x11rewritten_history\x18\x02 \x03(\v2$.pluggableharness.content.v1.MessageR\x10rewrittenHistory\"\x95\x01\n" + - "\fContextError\x12M\n" + - "\bcategory\x18\x01 \x01(\x0e21.pluggableharness.context.v1.ContextErrorCategoryR\bcategory\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\x12\x1c\n" + - "\tretryable\x18\x03 \x01(\bR\tretryable\"P\n" + - "\rRenderRequest\x12\x18\n" + - "\apayload\x18\x01 \x01(\fR\apayload\x12%\n" + - "\x0eschema_version\x18\x02 \x01(\tR\rschemaVersion\"L\n" + - "\x0eRenderResponse\x12:\n" + - "\x04tree\x18\x01 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\x04tree\"\x11\n" + - "\x0fDescribeRequest\"W\n" + - "\x10DescribeResponse\x12C\n" + - "\bproducer\x18\x01 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\bproducer*\x95\x02\n" + - "\x14ContextErrorCategory\x12&\n" + - "\"CONTEXT_ERROR_CATEGORY_UNSPECIFIED\x10\x00\x12-\n" + - ")CONTEXT_ERROR_CATEGORY_SOURCE_UNAVAILABLE\x10\x01\x12*\n" + - "&CONTEXT_ERROR_CATEGORY_BUDGET_EXCEEDED\x10\x02\x12*\n" + - "&CONTEXT_ERROR_CATEGORY_SCOPE_VIOLATION\x10\x03\x12*\n" + - "&CONTEXT_ERROR_CATEGORY_INVALID_REQUEST\x10\x04\x12\"\n" + - "\x1eCONTEXT_ERROR_CATEGORY_UNKNOWN\x10\x052\xb3\x04\n" + - "\x0eContextService\x12|\n" + - "\x0fGetCapabilities\x123.pluggableharness.context.v1.GetCapabilitiesRequest\x1a4.pluggableharness.context.v1.GetCapabilitiesResponse\x12j\n" + - "\tConfigure\x12-.pluggableharness.context.v1.ConfigureRequest\x1a..pluggableharness.context.v1.ConfigureResponse\x12k\n" + - "\n" + - "Contribute\x12+.pluggableharness.context.v1.ContextRequest\x1a0.pluggableharness.context.v1.ContextContribution\x12a\n" + - "\x06Render\x12*.pluggableharness.context.v1.RenderRequest\x1a+.pluggableharness.context.v1.RenderResponse\x12g\n" + - "\bDescribe\x12,.pluggableharness.context.v1.DescribeRequest\x1a-.pluggableharness.context.v1.DescribeResponseBBZ@github.com/pluggableharness/agent/pkg/context/proto/v1;contextv1b\x06proto3" - -var ( - file_pluggableharness_context_v1_context_proto_rawDescOnce sync.Once - file_pluggableharness_context_v1_context_proto_rawDescData []byte -) - -func file_pluggableharness_context_v1_context_proto_rawDescGZIP() []byte { - file_pluggableharness_context_v1_context_proto_rawDescOnce.Do(func() { - file_pluggableharness_context_v1_context_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_context_v1_context_proto_rawDesc), len(file_pluggableharness_context_v1_context_proto_rawDesc))) - }) - return file_pluggableharness_context_v1_context_proto_rawDescData -} - -var file_pluggableharness_context_v1_context_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_pluggableharness_context_v1_context_proto_msgTypes = make([]protoimpl.MessageInfo, 12) -var file_pluggableharness_context_v1_context_proto_goTypes = []any{ - (ContextErrorCategory)(0), // 0: pluggableharness.context.v1.ContextErrorCategory - (*GetCapabilitiesRequest)(nil), // 1: pluggableharness.context.v1.GetCapabilitiesRequest - (*GetCapabilitiesResponse)(nil), // 2: pluggableharness.context.v1.GetCapabilitiesResponse - (*ConfigureRequest)(nil), // 3: pluggableharness.context.v1.ConfigureRequest - (*ContextCapabilities)(nil), // 4: pluggableharness.context.v1.ContextCapabilities - (*ConfigureResponse)(nil), // 5: pluggableharness.context.v1.ConfigureResponse - (*ContextRequest)(nil), // 6: pluggableharness.context.v1.ContextRequest - (*ContextContribution)(nil), // 7: pluggableharness.context.v1.ContextContribution - (*ContextError)(nil), // 8: pluggableharness.context.v1.ContextError - (*RenderRequest)(nil), // 9: pluggableharness.context.v1.RenderRequest - (*RenderResponse)(nil), // 10: pluggableharness.context.v1.RenderResponse - (*DescribeRequest)(nil), // 11: pluggableharness.context.v1.DescribeRequest - (*DescribeResponse)(nil), // 12: pluggableharness.context.v1.DescribeResponse - (*structpb.Struct)(nil), // 13: google.protobuf.Struct - (v1.Stability)(0), // 14: pluggableharness.content.v1.Stability - (*v11.SlashCommandSpec)(nil), // 15: pluggableharness.slashcommand.v1.SlashCommandSpec - (*v12.ConfigSchema)(nil), // 16: pluggableharness.config.v1.ConfigSchema - (v13.HookPoint)(0), // 17: pluggableharness.common.v1.HookPoint - (*v14.ModelTarget)(nil), // 18: pluggableharness.model.v1.ModelTarget - (*v1.ContextSection)(nil), // 19: pluggableharness.content.v1.ContextSection - (*v1.Message)(nil), // 20: pluggableharness.content.v1.Message - (*v15.RenderTree)(nil), // 21: pluggableharness.render.v1.RenderTree - (*v13.ProducerRef)(nil), // 22: pluggableharness.common.v1.ProducerRef -} -var file_pluggableharness_context_v1_context_proto_depIdxs = []int32{ - 4, // 0: pluggableharness.context.v1.GetCapabilitiesResponse.capabilities:type_name -> pluggableharness.context.v1.ContextCapabilities - 13, // 1: pluggableharness.context.v1.ConfigureRequest.config:type_name -> google.protobuf.Struct - 14, // 2: pluggableharness.context.v1.ContextCapabilities.stability:type_name -> pluggableharness.content.v1.Stability - 15, // 3: pluggableharness.context.v1.ContextCapabilities.slash_commands:type_name -> pluggableharness.slashcommand.v1.SlashCommandSpec - 16, // 4: pluggableharness.context.v1.ContextCapabilities.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema - 17, // 5: pluggableharness.context.v1.ContextCapabilities.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint - 18, // 6: pluggableharness.context.v1.ContextRequest.model_target:type_name -> pluggableharness.model.v1.ModelTarget - 19, // 7: pluggableharness.context.v1.ContextRequest.prior_sections:type_name -> pluggableharness.content.v1.ContextSection - 20, // 8: pluggableharness.context.v1.ContextRequest.conversation_history:type_name -> pluggableharness.content.v1.Message - 19, // 9: pluggableharness.context.v1.ContextContribution.sections:type_name -> pluggableharness.content.v1.ContextSection - 20, // 10: pluggableharness.context.v1.ContextContribution.rewritten_history:type_name -> pluggableharness.content.v1.Message - 0, // 11: pluggableharness.context.v1.ContextError.category:type_name -> pluggableharness.context.v1.ContextErrorCategory - 21, // 12: pluggableharness.context.v1.RenderResponse.tree:type_name -> pluggableharness.render.v1.RenderTree - 22, // 13: pluggableharness.context.v1.DescribeResponse.producer:type_name -> pluggableharness.common.v1.ProducerRef - 1, // 14: pluggableharness.context.v1.ContextService.GetCapabilities:input_type -> pluggableharness.context.v1.GetCapabilitiesRequest - 3, // 15: pluggableharness.context.v1.ContextService.Configure:input_type -> pluggableharness.context.v1.ConfigureRequest - 6, // 16: pluggableharness.context.v1.ContextService.Contribute:input_type -> pluggableharness.context.v1.ContextRequest - 9, // 17: pluggableharness.context.v1.ContextService.Render:input_type -> pluggableharness.context.v1.RenderRequest - 11, // 18: pluggableharness.context.v1.ContextService.Describe:input_type -> pluggableharness.context.v1.DescribeRequest - 2, // 19: pluggableharness.context.v1.ContextService.GetCapabilities:output_type -> pluggableharness.context.v1.GetCapabilitiesResponse - 5, // 20: pluggableharness.context.v1.ContextService.Configure:output_type -> pluggableharness.context.v1.ConfigureResponse - 7, // 21: pluggableharness.context.v1.ContextService.Contribute:output_type -> pluggableharness.context.v1.ContextContribution - 10, // 22: pluggableharness.context.v1.ContextService.Render:output_type -> pluggableharness.context.v1.RenderResponse - 12, // 23: pluggableharness.context.v1.ContextService.Describe:output_type -> pluggableharness.context.v1.DescribeResponse - 19, // [19:24] is the sub-list for method output_type - 14, // [14:19] is the sub-list for method input_type - 14, // [14:14] is the sub-list for extension type_name - 14, // [14:14] is the sub-list for extension extendee - 0, // [0:14] is the sub-list for field type_name -} - -func init() { file_pluggableharness_context_v1_context_proto_init() } -func file_pluggableharness_context_v1_context_proto_init() { - if File_pluggableharness_context_v1_context_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_context_v1_context_proto_rawDesc), len(file_pluggableharness_context_v1_context_proto_rawDesc)), - NumEnums: 1, - NumMessages: 12, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_pluggableharness_context_v1_context_proto_goTypes, - DependencyIndexes: file_pluggableharness_context_v1_context_proto_depIdxs, - EnumInfos: file_pluggableharness_context_v1_context_proto_enumTypes, - MessageInfos: file_pluggableharness_context_v1_context_proto_msgTypes, - }.Build() - File_pluggableharness_context_v1_context_proto = out.File - file_pluggableharness_context_v1_context_proto_goTypes = nil - file_pluggableharness_context_v1_context_proto_depIdxs = nil -} diff --git a/pkg/context/proto/v1/errors.pb.go b/pkg/context/proto/v1/errors.pb.go new file mode 100644 index 0000000..e4acd23 --- /dev/null +++ b/pkg/context/proto/v1/errors.pb.go @@ -0,0 +1,235 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/context/v1/errors.proto + +package contextv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// ContextErrorCategory classifies a context provider's failures. +// context.md §10 — smaller than the model-provider taxonomy +// (model.md §8), but a plugin MUST still classify failures rather than +// collapsing them into one generic error. +type ContextErrorCategory int32 + +const ( + // Zero value. Never valid for a real error; its presence on the wire + // means a caller forgot to set the field. + ContextErrorCategory_CONTEXT_ERROR_CATEGORY_UNSPECIFIED ContextErrorCategory = 0 + // A declared file/glob/source was unreadable at call time (deleted + // mid-session, permission error). The kernel's expected reaction: drop + // the section for this turn, log; do not fail the turn. + ContextErrorCategory_CONTEXT_ERROR_CATEGORY_SOURCE_UNAVAILABLE ContextErrorCategory = 1 + // The provider's own section (or, for a compactor, its whole returned + // chain) exceeds token_budget. The kernel's expected reaction: reject + // per context.md §6; do not fail the turn for a non-compactor violator. + ContextErrorCategory_CONTEXT_ERROR_CATEGORY_BUDGET_EXCEEDED ContextErrorCategory = 2 + // A non-compactor provider mutated a section it doesn't own (context.md + // §5). The kernel's expected reaction: discard the entire response, + // restore the prior chain, log the violation. + ContextErrorCategory_CONTEXT_ERROR_CATEGORY_SCOPE_VIOLATION ContextErrorCategory = 3 + // A malformed request — a kernel/adapter bug. MUST NOT be retried as-is; + // MUST be logged with the full request shape. + ContextErrorCategory_CONTEXT_ERROR_CATEGORY_INVALID_REQUEST ContextErrorCategory = 4 + // Any other failure. MUST include the raw plugin error message for + // debugging. + ContextErrorCategory_CONTEXT_ERROR_CATEGORY_UNKNOWN ContextErrorCategory = 5 +) + +// Enum value maps for ContextErrorCategory. +var ( + ContextErrorCategory_name = map[int32]string{ + 0: "CONTEXT_ERROR_CATEGORY_UNSPECIFIED", + 1: "CONTEXT_ERROR_CATEGORY_SOURCE_UNAVAILABLE", + 2: "CONTEXT_ERROR_CATEGORY_BUDGET_EXCEEDED", + 3: "CONTEXT_ERROR_CATEGORY_SCOPE_VIOLATION", + 4: "CONTEXT_ERROR_CATEGORY_INVALID_REQUEST", + 5: "CONTEXT_ERROR_CATEGORY_UNKNOWN", + } + ContextErrorCategory_value = map[string]int32{ + "CONTEXT_ERROR_CATEGORY_UNSPECIFIED": 0, + "CONTEXT_ERROR_CATEGORY_SOURCE_UNAVAILABLE": 1, + "CONTEXT_ERROR_CATEGORY_BUDGET_EXCEEDED": 2, + "CONTEXT_ERROR_CATEGORY_SCOPE_VIOLATION": 3, + "CONTEXT_ERROR_CATEGORY_INVALID_REQUEST": 4, + "CONTEXT_ERROR_CATEGORY_UNKNOWN": 5, + } +) + +func (x ContextErrorCategory) Enum() *ContextErrorCategory { + p := new(ContextErrorCategory) + *p = x + return p +} + +func (x ContextErrorCategory) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ContextErrorCategory) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_context_v1_errors_proto_enumTypes[0].Descriptor() +} + +func (ContextErrorCategory) Type() protoreflect.EnumType { + return &file_pluggableharness_context_v1_errors_proto_enumTypes[0] +} + +func (x ContextErrorCategory) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ContextErrorCategory.Descriptor instead. +func (ContextErrorCategory) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_context_v1_errors_proto_rawDescGZIP(), []int{0} +} + +// ContextError is the structured error detail a context provider attaches +// to a failed RPC's gRPC status, per .claude/rules/grpc.md's error-taxonomy +// convention. +type ContextError struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Which category of failure this is. + Category ContextErrorCategory `protobuf:"varint,1,opt,name=category,proto3,enum=pluggableharness.context.v1.ContextErrorCategory" json:"category,omitempty"` + // Human-readable error detail, e.g. the raw plugin error message for + // CONTEXT_ERROR_CATEGORY_UNKNOWN. + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + // Whether the kernel may retry the call that produced this error. + Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ContextError) Reset() { + *x = ContextError{} + mi := &file_pluggableharness_context_v1_errors_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ContextError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextError) ProtoMessage() {} + +func (x *ContextError) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_context_v1_errors_proto_msgTypes[0] + 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 ContextError.ProtoReflect.Descriptor instead. +func (*ContextError) Descriptor() ([]byte, []int) { + return file_pluggableharness_context_v1_errors_proto_rawDescGZIP(), []int{0} +} + +func (x *ContextError) GetCategory() ContextErrorCategory { + if x != nil { + return x.Category + } + return ContextErrorCategory_CONTEXT_ERROR_CATEGORY_UNSPECIFIED +} + +func (x *ContextError) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ContextError) GetRetryable() bool { + if x != nil { + return x.Retryable + } + return false +} + +var File_pluggableharness_context_v1_errors_proto protoreflect.FileDescriptor + +const file_pluggableharness_context_v1_errors_proto_rawDesc = "" + + "\n" + + "(pluggableharness/context/v1/errors.proto\x12\x1bpluggableharness.context.v1\"\x95\x01\n" + + "\fContextError\x12M\n" + + "\bcategory\x18\x01 \x01(\x0e21.pluggableharness.context.v1.ContextErrorCategoryR\bcategory\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1c\n" + + "\tretryable\x18\x03 \x01(\bR\tretryable*\x95\x02\n" + + "\x14ContextErrorCategory\x12&\n" + + "\"CONTEXT_ERROR_CATEGORY_UNSPECIFIED\x10\x00\x12-\n" + + ")CONTEXT_ERROR_CATEGORY_SOURCE_UNAVAILABLE\x10\x01\x12*\n" + + "&CONTEXT_ERROR_CATEGORY_BUDGET_EXCEEDED\x10\x02\x12*\n" + + "&CONTEXT_ERROR_CATEGORY_SCOPE_VIOLATION\x10\x03\x12*\n" + + "&CONTEXT_ERROR_CATEGORY_INVALID_REQUEST\x10\x04\x12\"\n" + + "\x1eCONTEXT_ERROR_CATEGORY_UNKNOWN\x10\x05BBZ@github.com/pluggableharness/agent/pkg/context/proto/v1;contextv1b\x06proto3" + +var ( + file_pluggableharness_context_v1_errors_proto_rawDescOnce sync.Once + file_pluggableharness_context_v1_errors_proto_rawDescData []byte +) + +func file_pluggableharness_context_v1_errors_proto_rawDescGZIP() []byte { + file_pluggableharness_context_v1_errors_proto_rawDescOnce.Do(func() { + file_pluggableharness_context_v1_errors_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_context_v1_errors_proto_rawDesc), len(file_pluggableharness_context_v1_errors_proto_rawDesc))) + }) + return file_pluggableharness_context_v1_errors_proto_rawDescData +} + +var file_pluggableharness_context_v1_errors_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_pluggableharness_context_v1_errors_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_pluggableharness_context_v1_errors_proto_goTypes = []any{ + (ContextErrorCategory)(0), // 0: pluggableharness.context.v1.ContextErrorCategory + (*ContextError)(nil), // 1: pluggableharness.context.v1.ContextError +} +var file_pluggableharness_context_v1_errors_proto_depIdxs = []int32{ + 0, // 0: pluggableharness.context.v1.ContextError.category:type_name -> pluggableharness.context.v1.ContextErrorCategory + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_pluggableharness_context_v1_errors_proto_init() } +func file_pluggableharness_context_v1_errors_proto_init() { + if File_pluggableharness_context_v1_errors_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_context_v1_errors_proto_rawDesc), len(file_pluggableharness_context_v1_errors_proto_rawDesc)), + NumEnums: 1, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_context_v1_errors_proto_goTypes, + DependencyIndexes: file_pluggableharness_context_v1_errors_proto_depIdxs, + EnumInfos: file_pluggableharness_context_v1_errors_proto_enumTypes, + MessageInfos: file_pluggableharness_context_v1_errors_proto_msgTypes, + }.Build() + File_pluggableharness_context_v1_errors_proto = out.File + file_pluggableharness_context_v1_errors_proto_goTypes = nil + file_pluggableharness_context_v1_errors_proto_depIdxs = nil +} diff --git a/pkg/context/proto/v1/rpc_request.pb.go b/pkg/context/proto/v1/rpc_request.pb.go new file mode 100644 index 0000000..e1a39d0 --- /dev/null +++ b/pkg/context/proto/v1/rpc_request.pb.go @@ -0,0 +1,458 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/context/v1/rpc_request.proto + +package contextv1 + +import ( + v11 "github.com/pluggableharness/agent/pkg/content/proto/v1" + v1 "github.com/pluggableharness/agent/pkg/model/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// GetCapabilitiesRequest carries no fields — GetCapabilities takes no +// request-scoped parameters. +type GetCapabilitiesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCapabilitiesRequest) Reset() { + *x = GetCapabilitiesRequest{} + mi := &file_pluggableharness_context_v1_rpc_request_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCapabilitiesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCapabilitiesRequest) ProtoMessage() {} + +func (x *GetCapabilitiesRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_context_v1_rpc_request_proto_msgTypes[0] + 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 GetCapabilitiesRequest.ProtoReflect.Descriptor instead. +func (*GetCapabilitiesRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_context_v1_rpc_request_proto_rawDescGZIP(), []int{0} +} + +// ConfigureRequest wraps the provider's agent.hcl config block, already +// decoded via the schema-to-cty bridge, for the Configure RPC. +type ConfigureRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The decoded config object. Field contents are provider-specific — which + // file(s)/globs to read, max-hop @import depth, whether to strip HTML + // comments, etc. (context.md §3). + Config *structpb.Struct `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureRequest) Reset() { + *x = ConfigureRequest{} + mi := &file_pluggableharness_context_v1_rpc_request_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureRequest) ProtoMessage() {} + +func (x *ConfigureRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_context_v1_rpc_request_proto_msgTypes[1] + 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 ConfigureRequest.ProtoReflect.Descriptor instead. +func (*ConfigureRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_context_v1_rpc_request_proto_rawDescGZIP(), []int{1} +} + +func (x *ConfigureRequest) GetConfig() *structpb.Struct { + if x != nil { + return x.Config + } + return nil +} + +// ContextRequest is the context-assemble RPC's request, delivered to +// Contribute once per firing. context.md §4. +type ContextRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The current session's identifier. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // The parent session's identifier, when this session is a sub-agent + // session. Empty for a top-level session. + ParentSessionId string `protobuf:"bytes,2,opt,name=parent_session_id,json=parentSessionId,proto3" json:"parent_session_id,omitempty"` + // Which turn of the session this firing is for. A ULID, standardized + // across the whole protocol (matches plan.v1's turn_id field). Same field + // number as the retired int64 turn-number predecessor field; the rename + // is a sanctioned pre-release wire change, not a v2 bump. + TurnId string `protobuf:"bytes,3,opt,name=turn_id,json=turnId,proto3" json:"turn_id,omitempty"` + // The kernel's computed token allocation for this provider on this call. + // MUST be set. A returned section MUST NOT exceed this. context.md §4, + // §6. + TokenBudget int64 `protobuf:"varint,4,opt,name=token_budget,json=tokenBudget,proto3" json:"token_budget,omitempty"` + // The model this contribution is being assembled for, so the provider can + // tailor content (and compute tokens against the right budget) for the + // model that will actually consume it. MUST be set. context.md §4. + ModelTarget *v1.ModelTarget `protobuf:"bytes,5,opt,name=model_target,json=modelTarget,proto3" json:"model_target,omitempty"` + // Paths touched so far this session, enabling JIT-scoped contributions + // (e.g. a subdirectory-scoped convention-file reader). MAY be empty, e.g. + // at turn 0 / session start. context.md §4, §8. + FilesTouched []string `protobuf:"bytes,6,rep,name=files_touched,json=filesTouched,proto3" json:"files_touched,omitempty"` + // The session's current working directory. + WorkingDirectory string `protobuf:"bytes,7,opt,name=working_directory,json=workingDirectory,proto3" json:"working_directory,omitempty"` + // The accumulated output of earlier providers in this hook's + // declaration-order chain. MUST be set (MAY be empty on the first + // provider in the chain). context.md §4, §5. + PriorSections []*v11.ContextSection `protobuf:"bytes,8,rep,name=prior_sections,json=priorSections,proto3" json:"prior_sections,omitempty"` + // The session's conversation history. Populated ONLY for a provider whose + // ContextCapabilities.compactor == true; a non-compactor provider MUST + // NOT receive this — for those providers this field arrives empty, which + // is indistinguishable from (and semantically equivalent to) "not + // provided". context.md §5.1. + ConversationHistory []*v11.Message `protobuf:"bytes,9,rep,name=conversation_history,json=conversationHistory,proto3" json:"conversation_history,omitempty"` + // The current conversation-history token total, kernel-computed. One of + // the two signals a compactor provider needs to decide WHEN to compact + // without re-counting history itself — see data-types.md's compactor + // workflow discussion in the orbit of §5.1. + HistoryTokens int64 `protobuf:"varint,10,opt,name=history_tokens,json=historyTokens,proto3" json:"history_tokens,omitempty"` + // The total assembled context size of the previous turn (all providers' + // sections combined), kernel-computed. The second compact-timing signal, + // alongside history_tokens above. + AssembledTokensLastTurn int64 `protobuf:"varint,11,opt,name=assembled_tokens_last_turn,json=assembledTokensLastTurn,proto3" json:"assembled_tokens_last_turn,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ContextRequest) Reset() { + *x = ContextRequest{} + mi := &file_pluggableharness_context_v1_rpc_request_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ContextRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextRequest) ProtoMessage() {} + +func (x *ContextRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_context_v1_rpc_request_proto_msgTypes[2] + 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 ContextRequest.ProtoReflect.Descriptor instead. +func (*ContextRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_context_v1_rpc_request_proto_rawDescGZIP(), []int{2} +} + +func (x *ContextRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *ContextRequest) GetParentSessionId() string { + if x != nil { + return x.ParentSessionId + } + return "" +} + +func (x *ContextRequest) GetTurnId() string { + if x != nil { + return x.TurnId + } + return "" +} + +func (x *ContextRequest) GetTokenBudget() int64 { + if x != nil { + return x.TokenBudget + } + return 0 +} + +func (x *ContextRequest) GetModelTarget() *v1.ModelTarget { + if x != nil { + return x.ModelTarget + } + return nil +} + +func (x *ContextRequest) GetFilesTouched() []string { + if x != nil { + return x.FilesTouched + } + return nil +} + +func (x *ContextRequest) GetWorkingDirectory() string { + if x != nil { + return x.WorkingDirectory + } + return "" +} + +func (x *ContextRequest) GetPriorSections() []*v11.ContextSection { + if x != nil { + return x.PriorSections + } + return nil +} + +func (x *ContextRequest) GetConversationHistory() []*v11.Message { + if x != nil { + return x.ConversationHistory + } + return nil +} + +func (x *ContextRequest) GetHistoryTokens() int64 { + if x != nil { + return x.HistoryTokens + } + return 0 +} + +func (x *ContextRequest) GetAssembledTokensLastTurn() int64 { + if x != nil { + return x.AssembledTokensLastTurn + } + return 0 +} + +// RenderRequest carries the opaque payload for the optional Render RPC. +type RenderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The opaque emitted payload to render — see .claude/rules/grpc.md's + // Emit->Render->Paint carve-out. Never interpreted by the kernel or any + // other plugin; only the producing provider's own Render implementation + // understands its shape. + Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` + // The schema version this payload was emitted against, so a Render + // implementation can detect drift between the version it was built + // against and the version live in a running session. See + // ../frontend/render-tree.md#schema-versioning for the canonical + // definition of this field's semantics (owned by the frontend/widget + // workstream; this field just carries the same value). + SchemaVersion string `protobuf:"bytes,2,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenderRequest) Reset() { + *x = RenderRequest{} + mi := &file_pluggableharness_context_v1_rpc_request_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenderRequest) ProtoMessage() {} + +func (x *RenderRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_context_v1_rpc_request_proto_msgTypes[3] + 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 RenderRequest.ProtoReflect.Descriptor instead. +func (*RenderRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_context_v1_rpc_request_proto_rawDescGZIP(), []int{3} +} + +func (x *RenderRequest) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +func (x *RenderRequest) GetSchemaVersion() string { + if x != nil { + return x.SchemaVersion + } + return "" +} + +// DescribeRequest carries no fields — Describe takes no request-scoped +// parameters. +type DescribeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeRequest) Reset() { + *x = DescribeRequest{} + mi := &file_pluggableharness_context_v1_rpc_request_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeRequest) ProtoMessage() {} + +func (x *DescribeRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_context_v1_rpc_request_proto_msgTypes[4] + 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 DescribeRequest.ProtoReflect.Descriptor instead. +func (*DescribeRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_context_v1_rpc_request_proto_rawDescGZIP(), []int{4} +} + +var File_pluggableharness_context_v1_rpc_request_proto protoreflect.FileDescriptor + +const file_pluggableharness_context_v1_rpc_request_proto_rawDesc = "" + + "\n" + + "-pluggableharness/context/v1/rpc_request.proto\x12\x1bpluggableharness.context.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a'pluggableharness/content/v1/types.proto\x1a%pluggableharness/model/v1/types.proto\"\x18\n" + + "\x16GetCapabilitiesRequest\"C\n" + + "\x10ConfigureRequest\x12/\n" + + "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06config\"\xc5\x04\n" + + "\x0eContextRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12*\n" + + "\x11parent_session_id\x18\x02 \x01(\tR\x0fparentSessionId\x12\x17\n" + + "\aturn_id\x18\x03 \x01(\tR\x06turnId\x12!\n" + + "\ftoken_budget\x18\x04 \x01(\x03R\vtokenBudget\x12I\n" + + "\fmodel_target\x18\x05 \x01(\v2&.pluggableharness.model.v1.ModelTargetR\vmodelTarget\x12#\n" + + "\rfiles_touched\x18\x06 \x03(\tR\ffilesTouched\x12+\n" + + "\x11working_directory\x18\a \x01(\tR\x10workingDirectory\x12R\n" + + "\x0eprior_sections\x18\b \x03(\v2+.pluggableharness.content.v1.ContextSectionR\rpriorSections\x12W\n" + + "\x14conversation_history\x18\t \x03(\v2$.pluggableharness.content.v1.MessageR\x13conversationHistory\x12%\n" + + "\x0ehistory_tokens\x18\n" + + " \x01(\x03R\rhistoryTokens\x12;\n" + + "\x1aassembled_tokens_last_turn\x18\v \x01(\x03R\x17assembledTokensLastTurn\"P\n" + + "\rRenderRequest\x12\x18\n" + + "\apayload\x18\x01 \x01(\fR\apayload\x12%\n" + + "\x0eschema_version\x18\x02 \x01(\tR\rschemaVersion\"\x11\n" + + "\x0fDescribeRequestBBZ@github.com/pluggableharness/agent/pkg/context/proto/v1;contextv1b\x06proto3" + +var ( + file_pluggableharness_context_v1_rpc_request_proto_rawDescOnce sync.Once + file_pluggableharness_context_v1_rpc_request_proto_rawDescData []byte +) + +func file_pluggableharness_context_v1_rpc_request_proto_rawDescGZIP() []byte { + file_pluggableharness_context_v1_rpc_request_proto_rawDescOnce.Do(func() { + file_pluggableharness_context_v1_rpc_request_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_context_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_context_v1_rpc_request_proto_rawDesc))) + }) + return file_pluggableharness_context_v1_rpc_request_proto_rawDescData +} + +var file_pluggableharness_context_v1_rpc_request_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_pluggableharness_context_v1_rpc_request_proto_goTypes = []any{ + (*GetCapabilitiesRequest)(nil), // 0: pluggableharness.context.v1.GetCapabilitiesRequest + (*ConfigureRequest)(nil), // 1: pluggableharness.context.v1.ConfigureRequest + (*ContextRequest)(nil), // 2: pluggableharness.context.v1.ContextRequest + (*RenderRequest)(nil), // 3: pluggableharness.context.v1.RenderRequest + (*DescribeRequest)(nil), // 4: pluggableharness.context.v1.DescribeRequest + (*structpb.Struct)(nil), // 5: google.protobuf.Struct + (*v1.ModelTarget)(nil), // 6: pluggableharness.model.v1.ModelTarget + (*v11.ContextSection)(nil), // 7: pluggableharness.content.v1.ContextSection + (*v11.Message)(nil), // 8: pluggableharness.content.v1.Message +} +var file_pluggableharness_context_v1_rpc_request_proto_depIdxs = []int32{ + 5, // 0: pluggableharness.context.v1.ConfigureRequest.config:type_name -> google.protobuf.Struct + 6, // 1: pluggableharness.context.v1.ContextRequest.model_target:type_name -> pluggableharness.model.v1.ModelTarget + 7, // 2: pluggableharness.context.v1.ContextRequest.prior_sections:type_name -> pluggableharness.content.v1.ContextSection + 8, // 3: pluggableharness.context.v1.ContextRequest.conversation_history:type_name -> pluggableharness.content.v1.Message + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_pluggableharness_context_v1_rpc_request_proto_init() } +func file_pluggableharness_context_v1_rpc_request_proto_init() { + if File_pluggableharness_context_v1_rpc_request_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_context_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_context_v1_rpc_request_proto_rawDesc)), + NumEnums: 0, + NumMessages: 5, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_context_v1_rpc_request_proto_goTypes, + DependencyIndexes: file_pluggableharness_context_v1_rpc_request_proto_depIdxs, + MessageInfos: file_pluggableharness_context_v1_rpc_request_proto_msgTypes, + }.Build() + File_pluggableharness_context_v1_rpc_request_proto = out.File + file_pluggableharness_context_v1_rpc_request_proto_goTypes = nil + file_pluggableharness_context_v1_rpc_request_proto_depIdxs = nil +} diff --git a/pkg/context/proto/v1/rpc_response.pb.go b/pkg/context/proto/v1/rpc_response.pb.go new file mode 100644 index 0000000..68231f8 --- /dev/null +++ b/pkg/context/proto/v1/rpc_response.pb.go @@ -0,0 +1,348 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/context/v1/rpc_response.proto + +package contextv1 + +import ( + v12 "github.com/pluggableharness/agent/pkg/common/proto/v1" + v1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + v11 "github.com/pluggableharness/agent/pkg/render/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// GetCapabilitiesResponse wraps ContextCapabilities for the RPC signature, +// per this repo's per-RPC envelope convention (.claude/rules/proto.md). +type GetCapabilitiesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Capabilities *ContextCapabilities `protobuf:"bytes,1,opt,name=capabilities,proto3" json:"capabilities,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCapabilitiesResponse) Reset() { + *x = GetCapabilitiesResponse{} + mi := &file_pluggableharness_context_v1_rpc_response_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCapabilitiesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCapabilitiesResponse) ProtoMessage() {} + +func (x *GetCapabilitiesResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_context_v1_rpc_response_proto_msgTypes[0] + 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 GetCapabilitiesResponse.ProtoReflect.Descriptor instead. +func (*GetCapabilitiesResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_context_v1_rpc_response_proto_rawDescGZIP(), []int{0} +} + +func (x *GetCapabilitiesResponse) GetCapabilities() *ContextCapabilities { + if x != nil { + return x.Capabilities + } + return nil +} + +// ConfigureResponse is empty on success. A Configure failure (e.g. a +// declared source path/glob resolving to nothing) surfaces as a gRPC status +// carrying a ContextError in its structured detail, per +// .claude/rules/grpc.md — not as an in-band field on this message. +type ConfigureResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureResponse) Reset() { + *x = ConfigureResponse{} + mi := &file_pluggableharness_context_v1_rpc_response_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureResponse) ProtoMessage() {} + +func (x *ConfigureResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_context_v1_rpc_response_proto_msgTypes[1] + 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 ConfigureResponse.ProtoReflect.Descriptor instead. +func (*ConfigureResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_context_v1_rpc_response_proto_rawDescGZIP(), []int{1} +} + +// ContextContribution is Contribute's response: the full, possibly-modified +// section chain, with this provider's own section appended — never a +// delta. context.md §4. +type ContextContribution struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The full accumulated chain, in declaration order, including this + // provider's own new or updated section(s). + Sections []*v1.ContextSection `protobuf:"bytes,1,rep,name=sections,proto3" json:"sections,omitempty"` + // The session's conversation history, rewritten to replace what was sent + // in ContextRequest.conversation_history. MAY be included by a compactor + // provider (ContextCapabilities.compactor == true) alongside its section + // contribution. When present, the kernel MUST replace the turn's + // conversation history with this before the next model call. context.md + // §5.1. + RewrittenHistory []*v1.Message `protobuf:"bytes,2,rep,name=rewritten_history,json=rewrittenHistory,proto3" json:"rewritten_history,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ContextContribution) Reset() { + *x = ContextContribution{} + mi := &file_pluggableharness_context_v1_rpc_response_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ContextContribution) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextContribution) ProtoMessage() {} + +func (x *ContextContribution) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_context_v1_rpc_response_proto_msgTypes[2] + 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 ContextContribution.ProtoReflect.Descriptor instead. +func (*ContextContribution) Descriptor() ([]byte, []int) { + return file_pluggableharness_context_v1_rpc_response_proto_rawDescGZIP(), []int{2} +} + +func (x *ContextContribution) GetSections() []*v1.ContextSection { + if x != nil { + return x.Sections + } + return nil +} + +func (x *ContextContribution) GetRewrittenHistory() []*v1.Message { + if x != nil { + return x.RewrittenHistory + } + return nil +} + +// RenderResponse wraps the rendered output of the optional Render RPC. +type RenderResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The rendered tree, per the general Emit->Render->Paint pipeline + // (frontend.md §1). context.md §9. + Tree *v11.RenderTree `protobuf:"bytes,1,opt,name=tree,proto3" json:"tree,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenderResponse) Reset() { + *x = RenderResponse{} + mi := &file_pluggableharness_context_v1_rpc_response_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenderResponse) ProtoMessage() {} + +func (x *RenderResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_context_v1_rpc_response_proto_msgTypes[3] + 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 RenderResponse.ProtoReflect.Descriptor instead. +func (*RenderResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_context_v1_rpc_response_proto_rawDescGZIP(), []int{3} +} + +func (x *RenderResponse) GetTree() *v11.RenderTree { + if x != nil { + return x.Tree + } + return nil +} + +// DescribeResponse reports this plugin build's own identity. See the +// Describe RPC comment on ContextService above. +type DescribeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // This plugin build's identity: name, version, source, category, + // protocol_version. + Producer *v12.ProducerRef `protobuf:"bytes,1,opt,name=producer,proto3" json:"producer,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeResponse) Reset() { + *x = DescribeResponse{} + mi := &file_pluggableharness_context_v1_rpc_response_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeResponse) ProtoMessage() {} + +func (x *DescribeResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_context_v1_rpc_response_proto_msgTypes[4] + 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 DescribeResponse.ProtoReflect.Descriptor instead. +func (*DescribeResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_context_v1_rpc_response_proto_rawDescGZIP(), []int{4} +} + +func (x *DescribeResponse) GetProducer() *v12.ProducerRef { + if x != nil { + return x.Producer + } + return nil +} + +var File_pluggableharness_context_v1_rpc_response_proto protoreflect.FileDescriptor + +const file_pluggableharness_context_v1_rpc_response_proto_rawDesc = "" + + "\n" + + ".pluggableharness/context/v1/rpc_response.proto\x12\x1bpluggableharness.context.v1\x1a&pluggableharness/common/v1/types.proto\x1a'pluggableharness/content/v1/types.proto\x1a'pluggableharness/context/v1/types.proto\x1a&pluggableharness/render/v1/types.proto\"o\n" + + "\x17GetCapabilitiesResponse\x12T\n" + + "\fcapabilities\x18\x01 \x01(\v20.pluggableharness.context.v1.ContextCapabilitiesR\fcapabilities\"\x13\n" + + "\x11ConfigureResponse\"\xb1\x01\n" + + "\x13ContextContribution\x12G\n" + + "\bsections\x18\x01 \x03(\v2+.pluggableharness.content.v1.ContextSectionR\bsections\x12Q\n" + + "\x11rewritten_history\x18\x02 \x03(\v2$.pluggableharness.content.v1.MessageR\x10rewrittenHistory\"L\n" + + "\x0eRenderResponse\x12:\n" + + "\x04tree\x18\x01 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\x04tree\"W\n" + + "\x10DescribeResponse\x12C\n" + + "\bproducer\x18\x01 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\bproducerBBZ@github.com/pluggableharness/agent/pkg/context/proto/v1;contextv1b\x06proto3" + +var ( + file_pluggableharness_context_v1_rpc_response_proto_rawDescOnce sync.Once + file_pluggableharness_context_v1_rpc_response_proto_rawDescData []byte +) + +func file_pluggableharness_context_v1_rpc_response_proto_rawDescGZIP() []byte { + file_pluggableharness_context_v1_rpc_response_proto_rawDescOnce.Do(func() { + file_pluggableharness_context_v1_rpc_response_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_context_v1_rpc_response_proto_rawDesc), len(file_pluggableharness_context_v1_rpc_response_proto_rawDesc))) + }) + return file_pluggableharness_context_v1_rpc_response_proto_rawDescData +} + +var file_pluggableharness_context_v1_rpc_response_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_pluggableharness_context_v1_rpc_response_proto_goTypes = []any{ + (*GetCapabilitiesResponse)(nil), // 0: pluggableharness.context.v1.GetCapabilitiesResponse + (*ConfigureResponse)(nil), // 1: pluggableharness.context.v1.ConfigureResponse + (*ContextContribution)(nil), // 2: pluggableharness.context.v1.ContextContribution + (*RenderResponse)(nil), // 3: pluggableharness.context.v1.RenderResponse + (*DescribeResponse)(nil), // 4: pluggableharness.context.v1.DescribeResponse + (*ContextCapabilities)(nil), // 5: pluggableharness.context.v1.ContextCapabilities + (*v1.ContextSection)(nil), // 6: pluggableharness.content.v1.ContextSection + (*v1.Message)(nil), // 7: pluggableharness.content.v1.Message + (*v11.RenderTree)(nil), // 8: pluggableharness.render.v1.RenderTree + (*v12.ProducerRef)(nil), // 9: pluggableharness.common.v1.ProducerRef +} +var file_pluggableharness_context_v1_rpc_response_proto_depIdxs = []int32{ + 5, // 0: pluggableharness.context.v1.GetCapabilitiesResponse.capabilities:type_name -> pluggableharness.context.v1.ContextCapabilities + 6, // 1: pluggableharness.context.v1.ContextContribution.sections:type_name -> pluggableharness.content.v1.ContextSection + 7, // 2: pluggableharness.context.v1.ContextContribution.rewritten_history:type_name -> pluggableharness.content.v1.Message + 8, // 3: pluggableharness.context.v1.RenderResponse.tree:type_name -> pluggableharness.render.v1.RenderTree + 9, // 4: pluggableharness.context.v1.DescribeResponse.producer:type_name -> pluggableharness.common.v1.ProducerRef + 5, // [5:5] is the sub-list for method output_type + 5, // [5:5] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_pluggableharness_context_v1_rpc_response_proto_init() } +func file_pluggableharness_context_v1_rpc_response_proto_init() { + if File_pluggableharness_context_v1_rpc_response_proto != nil { + return + } + file_pluggableharness_context_v1_types_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_context_v1_rpc_response_proto_rawDesc), len(file_pluggableharness_context_v1_rpc_response_proto_rawDesc)), + NumEnums: 0, + NumMessages: 5, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_context_v1_rpc_response_proto_goTypes, + DependencyIndexes: file_pluggableharness_context_v1_rpc_response_proto_depIdxs, + MessageInfos: file_pluggableharness_context_v1_rpc_response_proto_msgTypes, + }.Build() + File_pluggableharness_context_v1_rpc_response_proto = out.File + file_pluggableharness_context_v1_rpc_response_proto_goTypes = nil + file_pluggableharness_context_v1_rpc_response_proto_depIdxs = nil +} diff --git a/pkg/context/proto/v1/service.pb.go b/pkg/context/proto/v1/service.pb.go new file mode 100644 index 0000000..ee4f282 --- /dev/null +++ b/pkg/context/proto/v1/service.pb.go @@ -0,0 +1,102 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/context/v1/service.proto + +// Package pluggableharness.context.v1 defines the context provider plugin protocol +// described in specifications/context.md — plugins that hook +// context-assemble and contribute content to the prompt before each model +// call (e.g. a CLAUDE.md reader, an AGENTS.md reader, a git-status/file-tree +// summarizer). See .claude/rules/proto.md. +// +// ContextSection and Stability — the section chain this protocol assembles +// and the turn-to-turn-change hint each section carries — are defined in +// pluggableharness.content.v1, not here: that chain is consumed by +// both this protocol and the model provider's completion request +// (forthcoming), so it's homed alongside content.v1's other shared content +// shapes rather than duplicated or owned by only one consumer. + +package contextv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_pluggableharness_context_v1_service_proto protoreflect.FileDescriptor + +const file_pluggableharness_context_v1_service_proto_rawDesc = "" + + "\n" + + ")pluggableharness/context/v1/service.proto\x12\x1bpluggableharness.context.v1\x1a-pluggableharness/context/v1/rpc_request.proto\x1a.pluggableharness/context/v1/rpc_response.proto2\xb3\x04\n" + + "\x0eContextService\x12|\n" + + "\x0fGetCapabilities\x123.pluggableharness.context.v1.GetCapabilitiesRequest\x1a4.pluggableharness.context.v1.GetCapabilitiesResponse\x12j\n" + + "\tConfigure\x12-.pluggableharness.context.v1.ConfigureRequest\x1a..pluggableharness.context.v1.ConfigureResponse\x12k\n" + + "\n" + + "Contribute\x12+.pluggableharness.context.v1.ContextRequest\x1a0.pluggableharness.context.v1.ContextContribution\x12a\n" + + "\x06Render\x12*.pluggableharness.context.v1.RenderRequest\x1a+.pluggableharness.context.v1.RenderResponse\x12g\n" + + "\bDescribe\x12,.pluggableharness.context.v1.DescribeRequest\x1a-.pluggableharness.context.v1.DescribeResponseBBZ@github.com/pluggableharness/agent/pkg/context/proto/v1;contextv1b\x06proto3" + +var file_pluggableharness_context_v1_service_proto_goTypes = []any{ + (*GetCapabilitiesRequest)(nil), // 0: pluggableharness.context.v1.GetCapabilitiesRequest + (*ConfigureRequest)(nil), // 1: pluggableharness.context.v1.ConfigureRequest + (*ContextRequest)(nil), // 2: pluggableharness.context.v1.ContextRequest + (*RenderRequest)(nil), // 3: pluggableharness.context.v1.RenderRequest + (*DescribeRequest)(nil), // 4: pluggableharness.context.v1.DescribeRequest + (*GetCapabilitiesResponse)(nil), // 5: pluggableharness.context.v1.GetCapabilitiesResponse + (*ConfigureResponse)(nil), // 6: pluggableharness.context.v1.ConfigureResponse + (*ContextContribution)(nil), // 7: pluggableharness.context.v1.ContextContribution + (*RenderResponse)(nil), // 8: pluggableharness.context.v1.RenderResponse + (*DescribeResponse)(nil), // 9: pluggableharness.context.v1.DescribeResponse +} +var file_pluggableharness_context_v1_service_proto_depIdxs = []int32{ + 0, // 0: pluggableharness.context.v1.ContextService.GetCapabilities:input_type -> pluggableharness.context.v1.GetCapabilitiesRequest + 1, // 1: pluggableharness.context.v1.ContextService.Configure:input_type -> pluggableharness.context.v1.ConfigureRequest + 2, // 2: pluggableharness.context.v1.ContextService.Contribute:input_type -> pluggableharness.context.v1.ContextRequest + 3, // 3: pluggableharness.context.v1.ContextService.Render:input_type -> pluggableharness.context.v1.RenderRequest + 4, // 4: pluggableharness.context.v1.ContextService.Describe:input_type -> pluggableharness.context.v1.DescribeRequest + 5, // 5: pluggableharness.context.v1.ContextService.GetCapabilities:output_type -> pluggableharness.context.v1.GetCapabilitiesResponse + 6, // 6: pluggableharness.context.v1.ContextService.Configure:output_type -> pluggableharness.context.v1.ConfigureResponse + 7, // 7: pluggableharness.context.v1.ContextService.Contribute:output_type -> pluggableharness.context.v1.ContextContribution + 8, // 8: pluggableharness.context.v1.ContextService.Render:output_type -> pluggableharness.context.v1.RenderResponse + 9, // 9: pluggableharness.context.v1.ContextService.Describe:output_type -> pluggableharness.context.v1.DescribeResponse + 5, // [5:10] is the sub-list for method output_type + 0, // [0:5] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_pluggableharness_context_v1_service_proto_init() } +func file_pluggableharness_context_v1_service_proto_init() { + if File_pluggableharness_context_v1_service_proto != nil { + return + } + file_pluggableharness_context_v1_rpc_request_proto_init() + file_pluggableharness_context_v1_rpc_response_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_context_v1_service_proto_rawDesc), len(file_pluggableharness_context_v1_service_proto_rawDesc)), + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_pluggableharness_context_v1_service_proto_goTypes, + DependencyIndexes: file_pluggableharness_context_v1_service_proto_depIdxs, + }.Build() + File_pluggableharness_context_v1_service_proto = out.File + file_pluggableharness_context_v1_service_proto_goTypes = nil + file_pluggableharness_context_v1_service_proto_depIdxs = nil +} diff --git a/pkg/context/proto/v1/context_grpc.pb.go b/pkg/context/proto/v1/service_grpc.pb.go similarity index 99% rename from pkg/context/proto/v1/context_grpc.pb.go rename to pkg/context/proto/v1/service_grpc.pb.go index dd12074..3a191d9 100644 --- a/pkg/context/proto/v1/context_grpc.pb.go +++ b/pkg/context/proto/v1/service_grpc.pb.go @@ -2,7 +2,7 @@ // versions: // - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) -// source: pluggableharness/context/v1/context.proto +// source: pluggableharness/context/v1/service.proto // Package pluggableharness.context.v1 defines the context provider plugin protocol // described in specifications/context.md — plugins that hook @@ -370,5 +370,5 @@ var ContextService_ServiceDesc = grpc.ServiceDesc{ }, }, Streams: []grpc.StreamDesc{}, - Metadata: "pluggableharness/context/v1/context.proto", + Metadata: "pluggableharness/context/v1/service.proto", } diff --git a/pkg/context/proto/v1/types.pb.go b/pkg/context/proto/v1/types.pb.go new file mode 100644 index 0000000..53b5fb6 --- /dev/null +++ b/pkg/context/proto/v1/types.pb.go @@ -0,0 +1,205 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/context/v1/types.proto + +package contextv1 + +import ( + v11 "github.com/pluggableharness/agent/pkg/common/proto/v1" + v12 "github.com/pluggableharness/agent/pkg/config/proto/v1" + v1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// ContextCapabilities reports a context provider's static properties. +// context.md §2. stability and compactor MUST be re-queryable cheaply and +// MUST NOT depend on a live read of the content source — a provider reading +// CLAUDE.md declares stability as a fixed property of the plugin, not a +// per-call judgment. +type ContextCapabilities struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The token cap this provider requests if agent.hcl does not override it. + // MUST be set. context.md §2, §6. + DefaultTokenBudget int64 `protobuf:"varint,1,opt,name=default_token_budget,json=defaultTokenBudget,proto3" json:"default_token_budget,omitempty"` + // Whether this provider's contributed content changes turn to turn. MUST + // be set. context.md §2, §7. + Stability v1.Stability `protobuf:"varint,2,opt,name=stability,proto3,enum=pluggableharness.content.v1.Stability" json:"stability,omitempty"` + // Whether this provider acts as a compactor: MAY rewrite, merge, or drop + // other providers' sections in the chain it receives, and MAY receive + // conversation_history on ContextRequest and return rewritten_history. + // MUST be set; defaults to false. context.md §5, §5.1. + Compactor bool `protobuf:"varint,3,opt,name=compactor,proto3" json:"compactor,omitempty"` + // Prompt-expansion slash commands this provider contributes. MAY be + // empty. context.md §2, configuration.md §5. A direct-invoke command + // is declared by a slashcommand.v1 provider instead + // (specifications/slashcommand/), never here. + SlashCommands []*v11.PromptExpansionSpec `protobuf:"bytes,4,rep,name=slash_commands,json=slashCommands,proto3" json:"slash_commands,omitempty"` + // This provider's agent.hcl config schema, advertised so the kernel knows + // what fields Configure accepts. configuration.md §4. + ConfigSchema *v12.ConfigSchema `protobuf:"bytes,5,opt,name=config_schema,json=configSchema,proto3" json:"config_schema,omitempty"` + // Which hook points (agent-loop/hook-dispatch.md) this provider declares + // HookSubscriberService.DispatchHook subscriptions for, advertised + // up front alongside its other static properties. MAY be empty — a + // context provider with no hook{} blocks in agent.hcl never has + // DispatchHook called on it. The enum itself lives in common.v1, not + // hook.v1 — hook.v1 imports this package's model/tool/plan dependencies, + // so a category capability message importing hook.v1 directly would + // cycle back through it; common.v1 is the shared leaf package instead. + SupportedHookPoints []v11.HookPoint `protobuf:"varint,6,rep,packed,name=supported_hook_points,json=supportedHookPoints,proto3,enum=pluggableharness.common.v1.HookPoint" json:"supported_hook_points,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ContextCapabilities) Reset() { + *x = ContextCapabilities{} + mi := &file_pluggableharness_context_v1_types_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ContextCapabilities) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextCapabilities) ProtoMessage() {} + +func (x *ContextCapabilities) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_context_v1_types_proto_msgTypes[0] + 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 ContextCapabilities.ProtoReflect.Descriptor instead. +func (*ContextCapabilities) Descriptor() ([]byte, []int) { + return file_pluggableharness_context_v1_types_proto_rawDescGZIP(), []int{0} +} + +func (x *ContextCapabilities) GetDefaultTokenBudget() int64 { + if x != nil { + return x.DefaultTokenBudget + } + return 0 +} + +func (x *ContextCapabilities) GetStability() v1.Stability { + if x != nil { + return x.Stability + } + return v1.Stability(0) +} + +func (x *ContextCapabilities) GetCompactor() bool { + if x != nil { + return x.Compactor + } + return false +} + +func (x *ContextCapabilities) GetSlashCommands() []*v11.PromptExpansionSpec { + if x != nil { + return x.SlashCommands + } + return nil +} + +func (x *ContextCapabilities) GetConfigSchema() *v12.ConfigSchema { + if x != nil { + return x.ConfigSchema + } + return nil +} + +func (x *ContextCapabilities) GetSupportedHookPoints() []v11.HookPoint { + if x != nil { + return x.SupportedHookPoints + } + return nil +} + +var File_pluggableharness_context_v1_types_proto protoreflect.FileDescriptor + +const file_pluggableharness_context_v1_types_proto_rawDesc = "" + + "\n" + + "'pluggableharness/context/v1/types.proto\x12\x1bpluggableharness.context.v1\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/config/v1/types.proto\x1a'pluggableharness/content/v1/types.proto\"\xad\x03\n" + + "\x13ContextCapabilities\x120\n" + + "\x14default_token_budget\x18\x01 \x01(\x03R\x12defaultTokenBudget\x12D\n" + + "\tstability\x18\x02 \x01(\x0e2&.pluggableharness.content.v1.StabilityR\tstability\x12\x1c\n" + + "\tcompactor\x18\x03 \x01(\bR\tcompactor\x12V\n" + + "\x0eslash_commands\x18\x04 \x03(\v2/.pluggableharness.common.v1.PromptExpansionSpecR\rslashCommands\x12M\n" + + "\rconfig_schema\x18\x05 \x01(\v2(.pluggableharness.config.v1.ConfigSchemaR\fconfigSchema\x12Y\n" + + "\x15supported_hook_points\x18\x06 \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPointsBBZ@github.com/pluggableharness/agent/pkg/context/proto/v1;contextv1b\x06proto3" + +var ( + file_pluggableharness_context_v1_types_proto_rawDescOnce sync.Once + file_pluggableharness_context_v1_types_proto_rawDescData []byte +) + +func file_pluggableharness_context_v1_types_proto_rawDescGZIP() []byte { + file_pluggableharness_context_v1_types_proto_rawDescOnce.Do(func() { + file_pluggableharness_context_v1_types_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_context_v1_types_proto_rawDesc), len(file_pluggableharness_context_v1_types_proto_rawDesc))) + }) + return file_pluggableharness_context_v1_types_proto_rawDescData +} + +var file_pluggableharness_context_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_pluggableharness_context_v1_types_proto_goTypes = []any{ + (*ContextCapabilities)(nil), // 0: pluggableharness.context.v1.ContextCapabilities + (v1.Stability)(0), // 1: pluggableharness.content.v1.Stability + (*v11.PromptExpansionSpec)(nil), // 2: pluggableharness.common.v1.PromptExpansionSpec + (*v12.ConfigSchema)(nil), // 3: pluggableharness.config.v1.ConfigSchema + (v11.HookPoint)(0), // 4: pluggableharness.common.v1.HookPoint +} +var file_pluggableharness_context_v1_types_proto_depIdxs = []int32{ + 1, // 0: pluggableharness.context.v1.ContextCapabilities.stability:type_name -> pluggableharness.content.v1.Stability + 2, // 1: pluggableharness.context.v1.ContextCapabilities.slash_commands:type_name -> pluggableharness.common.v1.PromptExpansionSpec + 3, // 2: pluggableharness.context.v1.ContextCapabilities.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema + 4, // 3: pluggableharness.context.v1.ContextCapabilities.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_pluggableharness_context_v1_types_proto_init() } +func file_pluggableharness_context_v1_types_proto_init() { + if File_pluggableharness_context_v1_types_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_context_v1_types_proto_rawDesc), len(file_pluggableharness_context_v1_types_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_context_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_context_v1_types_proto_depIdxs, + MessageInfos: file_pluggableharness_context_v1_types_proto_msgTypes, + }.Build() + File_pluggableharness_context_v1_types_proto = out.File + file_pluggableharness_context_v1_types_proto_goTypes = nil + file_pluggableharness_context_v1_types_proto_depIdxs = nil +} diff --git a/pkg/context/server.go b/pkg/context/server.go new file mode 100644 index 0000000..ed729e1 --- /dev/null +++ b/pkg/context/server.go @@ -0,0 +1,161 @@ +package context + +import ( + "context" + "fmt" + "log/slog" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + contextv1 "github.com/pluggableharness/agent/pkg/context/proto/v1" + "github.com/pluggableharness/agent/pkg/kernel" + "github.com/pluggableharness/agent/pkg/plugin" +) + +// Service adapts a Provider into a real +// pluggableharness.context.v1.ContextService gRPC server, satisfying both +// plugin.Service (for plugin.Config.Services) and +// contextv1.ContextServiceServer. Build one with NewService. +type Service struct { + contextv1.UnimplementedContextServiceServer + + provider Provider + identity plugin.Identity + callback *plugin.Callback +} + +var ( + _ plugin.Service = (*Service)(nil) + _ contextv1.ContextServiceServer = (*Service)(nil) +) + +// NewService returns a *Service that dispatches every ContextService RPC +// to provider, self-reporting identity via Describe and dialing callback +// for the kernel's CountTokens primitive on every Contribute call. +func NewService(provider Provider, identity plugin.Identity, callback *plugin.Callback) *Service { + return &Service{provider: provider, identity: identity, callback: callback} +} + +// Register registers this Service's ContextService handler on s, +// satisfying plugin.Service. +func (s *Service) Register(g *grpc.Server) { + contextv1.RegisterContextServiceServer(g, s) +} + +// GetCapabilities implements contextv1.ContextServiceServer by delegating +// to s.provider.GetCapabilities. +func (s *Service) GetCapabilities(ctx context.Context, _ *contextv1.GetCapabilitiesRequest) (*contextv1.GetCapabilitiesResponse, error) { + caps, err := s.provider.GetCapabilities(ctx) + if err != nil { + return nil, toStatusError(err) + } + return &contextv1.GetCapabilitiesResponse{Capabilities: capabilitiesToProto(caps)}, nil +} + +// Configure implements contextv1.ContextServiceServer by delegating to +// s.provider.Configure. +func (s *Service) Configure(ctx context.Context, req *contextv1.ConfigureRequest) (*contextv1.ConfigureResponse, error) { + if err := s.provider.Configure(ctx, req.GetConfig()); err != nil { + return nil, toStatusError(err) + } + return &contextv1.ConfigureResponse{}, nil +} + +// Contribute implements contextv1.ContextServiceServer: it converts req +// to its domain representation, dials the kernel callback client, and +// delegates the rest to contribute. Splitting the callback dial out of +// contribute is what makes contribute unit-testable with a fake +// *kernel.Client built over bufconn — plugin.Callback's own dial cannot +// be driven from outside pkg/plugin in a test (see +// pkg/plugin/callback_internal_test.go's identical documented +// limitation), so this seam is the only way to exercise the rest of +// Contribute's logic without a real hashicorp/go-plugin broker. +func (s *Service) Contribute(ctx context.Context, req *contextv1.ContextRequest) (*contextv1.ContextContribution, error) { + domainReq, err := requestFromProto(req) + if err != nil { + return nil, plugin.StatusError(codes.InvalidArgument, errorDomain, ErrorCategoryInvalidRequest.reason(), err.Error(), nil) + } + + client, err := s.callback.Client(ctx) + if err != nil { + return nil, plugin.StatusError(codes.Internal, errorDomain, ErrorCategoryUnknown.reason(), fmt.Sprintf("context: dial kernel callback: %v", err), nil) + } + + return s.contribute(ctx, req, domainReq, client) +} + +// contribute wires domainReq.CountTokens to client, delegates to +// s.provider.Contribute, converts the result back to wire form, and runs +// two best-effort, log-only defensive checks before returning — a +// provider budget or scope violation is the KERNEL's to enforce (it MUST +// discard the response), not this SDK's, but a loud log gives a provider +// author a fighting chance of noticing their own bug locally rather than +// only discovering it via a silently-discarded production response. +func (s *Service) contribute(ctx context.Context, req *contextv1.ContextRequest, domainReq *Request, client *kernel.Client) (*contextv1.ContextContribution, error) { + domainReq.CountTokens = func(ctx context.Context, text string) (int64, error) { + return countTokens(ctx, client, nil, text) + } + + contribution, err := s.provider.Contribute(ctx, domainReq) + if err != nil { + return nil, toStatusError(err) + } + + s.checkContribution(ctx, domainReq.PriorSections, contribution, req.GetTokenBudget()) + + return contributionToProto(contribution), nil +} + +// checkContribution logs (never fails the RPC) if contribution appears to +// violate the own-section-only rule (data-types.md#ordering--chaining) or +// its token budget (data-types.md#budget-mechanics). GetCapabilities MUST +// be cheap and side-effect-free per protocol.md, so calling it here to +// learn Compactor is within the spec's own stated cost expectation. +func (s *Service) checkContribution(ctx context.Context, prior []*Section, contribution *Contribution, tokenBudget int64) { + if contribution == nil { + return + } + + caps, capErr := s.provider.GetCapabilities(ctx) + compactor := capErr == nil && caps != nil && caps.Compactor + + if violation := CheckOwnSectionOnly(prior, contribution.Sections, s.identity.Name, compactor); violation != nil { + slog.Default().WarnContext(ctx, "context: provider scope violation", "provider", s.identity.Name, "error", violation) + } + + var ownTokens int64 + for _, sec := range contribution.Sections { + if compactor || sec.Provider == s.identity.Name { + ownTokens += sec.Tokens + } + } + if tokenBudget > 0 && ownTokens > tokenBudget { + slog.Default().WarnContext(ctx, "context: section(s) exceed allocated token budget", "provider", s.identity.Name, "tokens", ownTokens, "budget", tokenBudget) + } +} + +// Render implements contextv1.ContextServiceServer. If s.provider also +// implements Renderer, Render delegates to it; otherwise it reports +// codes.Unimplemented, which is protocol.md#render's documented signal +// for the kernel to fall back to its generic default rendering. +func (s *Service) Render(ctx context.Context, req *contextv1.RenderRequest) (*contextv1.RenderResponse, error) { + renderer, ok := s.provider.(Renderer) + if !ok { + return nil, status.Error(codes.Unimplemented, "context: provider does not implement Render") + } + tree, err := renderer.Render(ctx, &RenderRequest{Payload: req.GetPayload(), SchemaVersion: req.GetSchemaVersion()}) + if err != nil { + return nil, toStatusError(err) + } + return &contextv1.RenderResponse{Tree: tree}, nil +} + +// Describe implements contextv1.ContextServiceServer directly from +// s.identity, per configuration/lock-file.md's dev_overrides identity +// mechanism. +func (s *Service) Describe(context.Context, *contextv1.DescribeRequest) (*contextv1.DescribeResponse, error) { + return &contextv1.DescribeResponse{Producer: s.identity.ProducerRef(commonv1.Category_CATEGORY_CONTEXT)}, nil +} diff --git a/pkg/context/server_internal_test.go b/pkg/context/server_internal_test.go new file mode 100644 index 0000000..ae3bf95 --- /dev/null +++ b/pkg/context/server_internal_test.go @@ -0,0 +1,200 @@ +package context + +import ( + "context" + "testing" + + "google.golang.org/protobuf/types/known/structpb" + + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + contextv1 "github.com/pluggableharness/agent/pkg/context/proto/v1" + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" +) + +// This file is a white-box (package context, not context_test) test: +// Service.contribute and countTokens both need a *kernel.Client, which +// (per pkg/plugin/callback_internal_test.go's documented limitation) +// cannot be produced from a real *plugin.Callback outside pkg/plugin in a +// test. helpers_internal_test.go's newTestKernelClient builds one +// directly via kernel.NewClient over bufconn instead, bypassing +// plugin.Callback entirely — the seam server.go's Contribute/contribute +// split exists for. stubProvider is a second, package-local Provider fake +// (distinct from context_test.go's fakeProvider, which lives in the +// black-box package context_test and isn't visible here). +type stubProvider struct { + contribute func(*Request) (*Contribution, error) +} + +func (s *stubProvider) GetCapabilities(context.Context) (*Capabilities, error) { + return &Capabilities{}, nil +} + +func (s *stubProvider) Configure(context.Context, *structpb.Struct) error { return nil } + +func (s *stubProvider) Contribute(_ context.Context, req *Request) (*Contribution, error) { + return s.contribute(req) +} + +var _ Provider = (*stubProvider)(nil) + +func TestCountTokens_realRoundTrip(t *testing.T) { + t.Parallel() + + var gotText string + client := newTestKernelClient(t, &fakeKernelServer{ + countTokensFunc: func(req *kernelv1.CountTokensRequest) (*kernelv1.CountTokensResult, error) { + gotText = req.GetContent()[0].GetText().GetText() + return &kernelv1.CountTokensResult{Count: 42, Exact: true}, nil + }, + }) + + got, err := countTokens(t.Context(), client, nil, "hello world") + if err != nil { + t.Fatalf("countTokens() error = %v, want nil", err) + } + if got != 42 { + t.Errorf("countTokens() = %d, want 42 (proves it calls through the kernel, not a local heuristic)", got) + } + if gotText != "hello world" { + t.Errorf("kernel received text = %q, want %q", gotText, "hello world") + } +} + +func TestService_contribute_fullChainAndCountTokensWiring(t *testing.T) { + t.Parallel() + + client := newTestKernelClient(t, &fakeKernelServer{ + countTokensFunc: func(*kernelv1.CountTokensRequest) (*kernelv1.CountTokensResult, error) { + return &kernelv1.CountTokensResult{Count: 999, Exact: false}, nil + }, + }) + + var sawCountTokens bool + provider := &stubProvider{ + contribute: func(req *Request) (*Contribution, error) { + if req.CountTokens == nil { + t.Fatal("req.CountTokens = nil, want a bound closure") + } + count, err := req.CountTokens(context.Background(), "some text") + if err != nil { + t.Fatalf("req.CountTokens() error = %v, want nil", err) + } + if count != 999 { + t.Errorf("req.CountTokens() = %d, want 999 (from the fake kernel server)", count) + } + sawCountTokens = true + + // Deliberately exceeds the request's token_budget (10) to + // exercise Service.checkContribution's budget-warning branch + // — it MUST log, not fail the RPC. + return &Contribution{ + Sections: append(req.PriorSections, &Section{ + Provider: "claude-md", Label: "CLAUDE.md", Content: "way too much", Tokens: 999, + }), + }, nil + }, + } + svc := &Service{provider: provider, identity: plugin.Identity{Name: "claude-md"}, callback: plugin.NewCallback()} + + req := &contextv1.ContextRequest{TokenBudget: 10} + domainReq, err := requestFromProto(req) + if err != nil { + t.Fatalf("requestFromProto() error = %v, want nil", err) + } + + resp, err := svc.contribute(t.Context(), req, domainReq, client) + if err != nil { + t.Fatalf("contribute() error = %v, want nil", err) + } + if !sawCountTokens { + t.Fatal("provider never called req.CountTokens") + } + if len(resp.GetSections()) != 1 { + t.Fatalf("contribute() sections len = %d, want 1", len(resp.GetSections())) + } + if got := resp.GetSections()[0].GetProvider(); got != "claude-md" { + t.Errorf("appended section provider = %q, want %q", got, "claude-md") + } +} + +func TestService_contribute_fullChainNotDelta(t *testing.T) { + t.Parallel() + + client := newTestKernelClient(t, &fakeKernelServer{ + countTokensFunc: func(*kernelv1.CountTokensRequest) (*kernelv1.CountTokensResult, error) { + return &kernelv1.CountTokensResult{Count: 1}, nil + }, + }) + + provider := &stubProvider{ + contribute: func(req *Request) (*Contribution, error) { + return &Contribution{ + Sections: append(req.PriorSections, &Section{Provider: "agents-md", Label: "AGENTS.md (src/auth)"}), + }, nil + }, + } + svc := &Service{provider: provider, identity: plugin.Identity{Name: "agents-md"}, callback: plugin.NewCallback()} + + req := &contextv1.ContextRequest{ + PriorSections: []*contentv1.ContextSection{ + {Provider: "claude-md", Label: "CLAUDE.md", Content: []*contentv1.ContentBlock{ + {Block: &contentv1.ContentBlock_Text{Text: &contentv1.TextBlock{Text: "conventions"}}}, + }}, + }, + } + domainReq, err := requestFromProto(req) + if err != nil { + t.Fatalf("requestFromProto() error = %v, want nil", err) + } + + resp, err := svc.contribute(t.Context(), req, domainReq, client) + if err != nil { + t.Fatalf("contribute() error = %v, want nil", err) + } + if len(resp.GetSections()) != 2 { + t.Fatalf("contribute() sections len = %d, want 2 (prior + own, not a delta)", len(resp.GetSections())) + } + if got := resp.GetSections()[0].GetProvider(); got != "claude-md" { + t.Errorf("first section provider = %q, want %q (unchanged prior section)", got, "claude-md") + } +} + +func TestService_contribute_scopeViolationLoggedNotFailed(t *testing.T) { + t.Parallel() + + client := newTestKernelClient(t, &fakeKernelServer{ + countTokensFunc: func(*kernelv1.CountTokensRequest) (*kernelv1.CountTokensResult, error) { + return &kernelv1.CountTokensResult{Count: 1}, nil + }, + }) + + provider := &stubProvider{ + contribute: func(*Request) (*Contribution, error) { + // Mutates a section it doesn't own without declaring + // compactor — a scope violation. Service.contribute MUST NOT + // fail the RPC for this (the kernel is the enforcement + // authority); it only logs. + return &Contribution{ + Sections: []*Section{{Provider: "someone-else", Label: "hijacked"}}, + }, nil + }, + } + svc := &Service{provider: provider, identity: plugin.Identity{Name: "claude-md"}, callback: plugin.NewCallback()} + + req := &contextv1.ContextRequest{ + PriorSections: []*contentv1.ContextSection{ + {Provider: "claude-md", Label: "CLAUDE.md", Content: []*contentv1.ContentBlock{ + {Block: &contentv1.ContentBlock_Text{Text: &contentv1.TextBlock{Text: "conventions"}}}, + }}, + }, + } + domainReq, err := requestFromProto(req) + if err != nil { + t.Fatalf("requestFromProto() error = %v, want nil", err) + } + + if _, err := svc.contribute(t.Context(), req, domainReq, client); err != nil { + t.Fatalf("contribute() error = %v, want nil (scope violation is log-only)", err) + } +} diff --git a/pkg/context/server_test.go b/pkg/context/server_test.go new file mode 100644 index 0000000..40b93d7 --- /dev/null +++ b/pkg/context/server_test.go @@ -0,0 +1,259 @@ +package context_test + +import ( + "context" + "net" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/types/known/structpb" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + pluggablecontext "github.com/pluggableharness/agent/pkg/context" + contextv1 "github.com/pluggableharness/agent/pkg/context/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" + "github.com/pluggableharness/agent/pkg/render" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// newTestContextClient starts svc on an in-memory bufconn listener and +// returns a contextv1.ContextServiceClient dialed against it — a real +// gRPC round trip over the actual generated ContextService, not a direct +// Go method call, so these tests exercise wire marshaling and gRPC status +// mapping too. +func newTestContextClient(t *testing.T, svc *pluggablecontext.Service) contextv1.ContextServiceClient { + t.Helper() + + const bufSize = 1 << 20 + lis := bufconn.Listen(bufSize) + + gs := grpc.NewServer() + svc.Register(gs) + go func() { _ = gs.Serve(lis) }() + t.Cleanup(gs.Stop) + + dialer := func(context.Context, string) (net.Conn, error) { return lis.Dial() } + conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(dialer), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("grpc.NewClient: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + return contextv1.NewContextServiceClient(conn) +} + +func testIdentity() plugin.Identity { + return plugin.Identity{Name: "claude-md", Version: "1.0.0", Source: "github.com/agentco/context-claude-md"} +} + +func TestService_GetCapabilities(t *testing.T) { + t.Parallel() + + schema := &configv1.ConfigSchema{} + provider := &fakeProvider{ + getCapabilitiesFunc: func() (*pluggablecontext.Capabilities, error) { + return pluggablecontext.NewCapabilities(2000, pluggablecontext.StabilityStatic, schema, pluggablecontext.WithCompactor()), nil + }, + } + client := newTestContextClient(t, pluggablecontext.NewService(provider, testIdentity(), plugin.NewCallback())) + + resp, err := client.GetCapabilities(t.Context(), &contextv1.GetCapabilitiesRequest{}) + if err != nil { + t.Fatalf("GetCapabilities() error = %v, want nil", err) + } + caps := resp.GetCapabilities() + if caps.GetDefaultTokenBudget() != 2000 { + t.Errorf("DefaultTokenBudget = %d, want 2000", caps.GetDefaultTokenBudget()) + } + if !caps.GetCompactor() { + t.Error("Compactor = false, want true") + } +} + +func TestService_GetCapabilities_error(t *testing.T) { + t.Parallel() + + provider := &fakeProvider{ + getCapabilitiesFunc: func() (*pluggablecontext.Capabilities, error) { + return nil, &pluggablecontext.Error{Category: pluggablecontext.ErrorCategoryUnknown, Message: "boom"} + }, + } + client := newTestContextClient(t, pluggablecontext.NewService(provider, testIdentity(), plugin.NewCallback())) + + _, err := client.GetCapabilities(t.Context(), &contextv1.GetCapabilitiesRequest{}) + st, ok := status.FromError(err) + if !ok { + t.Fatalf("status.FromError(%v) ok = false, want true", err) + } + if st.Code() != codes.Internal { + t.Errorf("Code() = %v, want %v", st.Code(), codes.Internal) + } +} + +func TestService_Configure(t *testing.T) { + t.Parallel() + + var gotConfig *structpb.Struct + provider := &fakeProvider{ + configureFunc: func(cfg *structpb.Struct) error { + gotConfig = cfg + return nil + }, + } + client := newTestContextClient(t, pluggablecontext.NewService(provider, testIdentity(), plugin.NewCallback())) + + cfg, err := structpb.NewStruct(map[string]any{"path": "CLAUDE.md"}) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + if _, err := client.Configure(t.Context(), &contextv1.ConfigureRequest{Config: cfg}); err != nil { + t.Fatalf("Configure() error = %v, want nil", err) + } + if gotConfig.GetFields()["path"].GetStringValue() != "CLAUDE.md" { + t.Errorf("Configure() delivered config = %v, want path=CLAUDE.md", gotConfig) + } +} + +func TestService_Configure_error(t *testing.T) { + t.Parallel() + + provider := &fakeProvider{ + configureFunc: func(*structpb.Struct) error { + return &pluggablecontext.Error{Category: pluggablecontext.ErrorCategorySourceUnavailable, Message: "glob resolves to nothing"} + }, + } + client := newTestContextClient(t, pluggablecontext.NewService(provider, testIdentity(), plugin.NewCallback())) + + _, err := client.Configure(t.Context(), &contextv1.ConfigureRequest{}) + st, ok := status.FromError(err) + if !ok { + t.Fatalf("status.FromError(%v) ok = false, want true", err) + } + if st.Code() != codes.Unavailable { + t.Errorf("Code() = %v, want %v", st.Code(), codes.Unavailable) + } +} + +func TestService_Contribute_invalidRequest(t *testing.T) { + t.Parallel() + + provider := &fakeProvider{} + client := newTestContextClient(t, pluggablecontext.NewService(provider, testIdentity(), plugin.NewCallback())) + + req := &contextv1.ContextRequest{ + PriorSections: []*contentv1.ContextSection{ + {Provider: "other", Content: []*contentv1.ContentBlock{{Block: &contentv1.ContentBlock_Image{Image: &contentv1.ImageBlock{}}}}}, + }, + } + _, err := client.Contribute(t.Context(), req) + st, ok := status.FromError(err) + if !ok { + t.Fatalf("status.FromError(%v) ok = false, want true", err) + } + if st.Code() != codes.InvalidArgument { + t.Errorf("Code() = %v, want %v", st.Code(), codes.InvalidArgument) + } +} + +func TestService_Contribute_callbackDialFailure(t *testing.T) { + t.Parallel() + + // plugin.NewCallback() with no broker set (never launched via + // plugin.Serve) always fails to dial — see + // pkg/plugin/callback_internal_test.go's identical documented + // limitation. This proves Service.Contribute surfaces that failure + // as a real gRPC error rather than panicking or hanging. + provider := &fakeProvider{} + client := newTestContextClient(t, pluggablecontext.NewService(provider, testIdentity(), plugin.NewCallback())) + + _, err := client.Contribute(t.Context(), &contextv1.ContextRequest{}) + st, ok := status.FromError(err) + if !ok { + t.Fatalf("status.FromError(%v) ok = false, want true", err) + } + if st.Code() != codes.Internal { + t.Errorf("Code() = %v, want %v", st.Code(), codes.Internal) + } +} + +func TestService_Render_unimplemented(t *testing.T) { + t.Parallel() + + provider := &fakeProvider{} + client := newTestContextClient(t, pluggablecontext.NewService(provider, testIdentity(), plugin.NewCallback())) + + _, err := client.Render(t.Context(), &contextv1.RenderRequest{SchemaVersion: "v1"}) + st, ok := status.FromError(err) + if !ok { + t.Fatalf("status.FromError(%v) ok = false, want true", err) + } + if st.Code() != codes.Unimplemented { + t.Errorf("Code() = %v, want %v", st.Code(), codes.Unimplemented) + } +} + +// rendererProvider implements both Provider and Renderer. +type rendererProvider struct { + fakeProvider + + renderFunc func(*pluggablecontext.RenderRequest) (*renderv1.RenderTree, error) +} + +func (r *rendererProvider) Render(_ context.Context, req *pluggablecontext.RenderRequest) (*renderv1.RenderTree, error) { + return r.renderFunc(req) +} + +var _ pluggablecontext.Renderer = (*rendererProvider)(nil) + +func TestService_Render_implemented(t *testing.T) { + t.Parallel() + + var gotSchemaVersion string + provider := &rendererProvider{ + renderFunc: func(req *pluggablecontext.RenderRequest) (*renderv1.RenderTree, error) { + gotSchemaVersion = req.SchemaVersion + return render.Tree(render.Text("collapsed CLAUDE.md")), nil + }, + } + client := newTestContextClient(t, pluggablecontext.NewService(provider, testIdentity(), plugin.NewCallback())) + + resp, err := client.Render(t.Context(), &contextv1.RenderRequest{SchemaVersion: "v1", Payload: []byte("payload")}) + if err != nil { + t.Fatalf("Render() error = %v, want nil", err) + } + if gotSchemaVersion != "v1" { + t.Errorf("Renderer received SchemaVersion = %q, want %q", gotSchemaVersion, "v1") + } + if resp.GetTree().GetRoot() == nil { + t.Error("Render() response tree root = nil, want a node") + } +} + +func TestService_Describe(t *testing.T) { + t.Parallel() + + provider := &fakeProvider{} + identity := testIdentity() + client := newTestContextClient(t, pluggablecontext.NewService(provider, identity, plugin.NewCallback())) + + resp, err := client.Describe(t.Context(), &contextv1.DescribeRequest{}) + if err != nil { + t.Fatalf("Describe() error = %v, want nil", err) + } + producer := resp.GetProducer() + if producer.GetName() != identity.Name { + t.Errorf("Producer.Name = %q, want %q", producer.GetName(), identity.Name) + } + if producer.GetVersion() != identity.Version { + t.Errorf("Producer.Version = %q, want %q", producer.GetVersion(), identity.Version) + } + if producer.GetCategory() != commonv1.Category_CATEGORY_CONTEXT { + t.Errorf("Producer.Category = %v, want CATEGORY_CONTEXT", producer.GetCategory()) + } +} diff --git a/pkg/event/proto/v1/event.pb.go b/pkg/event/proto/v1/events.pb.go similarity index 86% rename from pkg/event/proto/v1/event.pb.go rename to pkg/event/proto/v1/events.pb.go index aced4a3..1c371c1 100644 --- a/pkg/event/proto/v1/event.pb.go +++ b/pkg/event/proto/v1/events.pb.go @@ -2,7 +2,7 @@ // versions: // protoc-gen-go v1.36.11 // protoc (unknown) -// source: pluggableharness/event/v1/event.proto +// source: pluggableharness/event/v1/events.proto // Package pluggableharness.event.v1 defines the decoded payload shape // for every pluggableharness.kernel.v1.EventKind — the concrete @@ -112,7 +112,7 @@ type MessageEvent struct { func (x *MessageEvent) Reset() { *x = MessageEvent{} - mi := &file_pluggableharness_event_v1_event_proto_msgTypes[0] + mi := &file_pluggableharness_event_v1_events_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -124,7 +124,7 @@ func (x *MessageEvent) String() string { func (*MessageEvent) ProtoMessage() {} func (x *MessageEvent) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_event_v1_event_proto_msgTypes[0] + mi := &file_pluggableharness_event_v1_events_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -137,7 +137,7 @@ func (x *MessageEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use MessageEvent.ProtoReflect.Descriptor instead. func (*MessageEvent) Descriptor() ([]byte, []int) { - return file_pluggableharness_event_v1_event_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_event_v1_events_proto_rawDescGZIP(), []int{0} } func (x *MessageEvent) GetMessage() *v1.Message { @@ -179,7 +179,7 @@ type ToolCallEvent struct { func (x *ToolCallEvent) Reset() { *x = ToolCallEvent{} - mi := &file_pluggableharness_event_v1_event_proto_msgTypes[1] + mi := &file_pluggableharness_event_v1_events_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -191,7 +191,7 @@ func (x *ToolCallEvent) String() string { func (*ToolCallEvent) ProtoMessage() {} func (x *ToolCallEvent) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_event_v1_event_proto_msgTypes[1] + mi := &file_pluggableharness_event_v1_events_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -204,7 +204,7 @@ func (x *ToolCallEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ToolCallEvent.ProtoReflect.Descriptor instead. func (*ToolCallEvent) Descriptor() ([]byte, []int) { - return file_pluggableharness_event_v1_event_proto_rawDescGZIP(), []int{1} + return file_pluggableharness_event_v1_events_proto_rawDescGZIP(), []int{1} } func (x *ToolCallEvent) GetCall() *v13.ToolCall { @@ -232,7 +232,7 @@ type ToolResultEvent struct { func (x *ToolResultEvent) Reset() { *x = ToolResultEvent{} - mi := &file_pluggableharness_event_v1_event_proto_msgTypes[2] + mi := &file_pluggableharness_event_v1_events_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -244,7 +244,7 @@ func (x *ToolResultEvent) String() string { func (*ToolResultEvent) ProtoMessage() {} func (x *ToolResultEvent) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_event_v1_event_proto_msgTypes[2] + mi := &file_pluggableharness_event_v1_events_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -257,7 +257,7 @@ func (x *ToolResultEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ToolResultEvent.ProtoReflect.Descriptor instead. func (*ToolResultEvent) Descriptor() ([]byte, []int) { - return file_pluggableharness_event_v1_event_proto_rawDescGZIP(), []int{2} + return file_pluggableharness_event_v1_events_proto_rawDescGZIP(), []int{2} } func (x *ToolResultEvent) GetToolCallId() string { @@ -323,7 +323,7 @@ type PlanEvent struct { func (x *PlanEvent) Reset() { *x = PlanEvent{} - mi := &file_pluggableharness_event_v1_event_proto_msgTypes[3] + mi := &file_pluggableharness_event_v1_events_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -335,7 +335,7 @@ func (x *PlanEvent) String() string { func (*PlanEvent) ProtoMessage() {} func (x *PlanEvent) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_event_v1_event_proto_msgTypes[3] + mi := &file_pluggableharness_event_v1_events_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -348,7 +348,7 @@ func (x *PlanEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use PlanEvent.ProtoReflect.Descriptor instead. func (*PlanEvent) Descriptor() ([]byte, []int) { - return file_pluggableharness_event_v1_event_proto_rawDescGZIP(), []int{3} + return file_pluggableharness_event_v1_events_proto_rawDescGZIP(), []int{3} } func (x *PlanEvent) GetPlan() *v14.Plan { @@ -367,7 +367,7 @@ type ApplyEvent struct { // plan.v1.ApplyResult — homed in plan.v1, not redefined here, so this // event's payload and hook.v1.PostApplyPayload's subject are the exact // same message rather than two independently-evolving shapes for the - // same data (plan.proto's own ApplyResult comment documents this + // same data (plan/v1/types.proto's own ApplyResult comment documents this // sharing rationale). Result *v14.ApplyResult `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` unknownFields protoimpl.UnknownFields @@ -376,7 +376,7 @@ type ApplyEvent struct { func (x *ApplyEvent) Reset() { *x = ApplyEvent{} - mi := &file_pluggableharness_event_v1_event_proto_msgTypes[4] + mi := &file_pluggableharness_event_v1_events_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -388,7 +388,7 @@ func (x *ApplyEvent) String() string { func (*ApplyEvent) ProtoMessage() {} func (x *ApplyEvent) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_event_v1_event_proto_msgTypes[4] + mi := &file_pluggableharness_event_v1_events_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -401,7 +401,7 @@ func (x *ApplyEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ApplyEvent.ProtoReflect.Descriptor instead. func (*ApplyEvent) Descriptor() ([]byte, []int) { - return file_pluggableharness_event_v1_event_proto_rawDescGZIP(), []int{4} + return file_pluggableharness_event_v1_events_proto_rawDescGZIP(), []int{4} } func (x *ApplyEvent) GetResult() *v14.ApplyResult { @@ -437,7 +437,7 @@ type ContextContributionEvent struct { func (x *ContextContributionEvent) Reset() { *x = ContextContributionEvent{} - mi := &file_pluggableharness_event_v1_event_proto_msgTypes[5] + mi := &file_pluggableharness_event_v1_events_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -449,7 +449,7 @@ func (x *ContextContributionEvent) String() string { func (*ContextContributionEvent) ProtoMessage() {} func (x *ContextContributionEvent) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_event_v1_event_proto_msgTypes[5] + mi := &file_pluggableharness_event_v1_events_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -462,7 +462,7 @@ func (x *ContextContributionEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ContextContributionEvent.ProtoReflect.Descriptor instead. func (*ContextContributionEvent) Descriptor() ([]byte, []int) { - return file_pluggableharness_event_v1_event_proto_rawDescGZIP(), []int{5} + return file_pluggableharness_event_v1_events_proto_rawDescGZIP(), []int{5} } func (x *ContextContributionEvent) GetContent() []*v1.ContentBlock { @@ -510,7 +510,7 @@ type MemoryMutationEvent struct { func (x *MemoryMutationEvent) Reset() { *x = MemoryMutationEvent{} - mi := &file_pluggableharness_event_v1_event_proto_msgTypes[6] + mi := &file_pluggableharness_event_v1_events_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -522,7 +522,7 @@ func (x *MemoryMutationEvent) String() string { func (*MemoryMutationEvent) ProtoMessage() {} func (x *MemoryMutationEvent) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_event_v1_event_proto_msgTypes[6] + mi := &file_pluggableharness_event_v1_events_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -535,7 +535,7 @@ func (x *MemoryMutationEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use MemoryMutationEvent.ProtoReflect.Descriptor instead. func (*MemoryMutationEvent) Descriptor() ([]byte, []int) { - return file_pluggableharness_event_v1_event_proto_rawDescGZIP(), []int{6} + return file_pluggableharness_event_v1_events_proto_rawDescGZIP(), []int{6} } func (x *MemoryMutationEvent) GetRecordId() string { @@ -576,7 +576,7 @@ type HookErrorEvent struct { func (x *HookErrorEvent) Reset() { *x = HookErrorEvent{} - mi := &file_pluggableharness_event_v1_event_proto_msgTypes[7] + mi := &file_pluggableharness_event_v1_events_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -588,7 +588,7 @@ func (x *HookErrorEvent) String() string { func (*HookErrorEvent) ProtoMessage() {} func (x *HookErrorEvent) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_event_v1_event_proto_msgTypes[7] + mi := &file_pluggableharness_event_v1_events_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -601,7 +601,7 @@ func (x *HookErrorEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use HookErrorEvent.ProtoReflect.Descriptor instead. func (*HookErrorEvent) Descriptor() ([]byte, []int) { - return file_pluggableharness_event_v1_event_proto_rawDescGZIP(), []int{7} + return file_pluggableharness_event_v1_events_proto_rawDescGZIP(), []int{7} } func (x *HookErrorEvent) GetError() *v15.HookError { @@ -611,11 +611,11 @@ func (x *HookErrorEvent) GetError() *v15.HookError { return nil } -var File_pluggableharness_event_v1_event_proto protoreflect.FileDescriptor +var File_pluggableharness_event_v1_events_proto protoreflect.FileDescriptor -const file_pluggableharness_event_v1_event_proto_rawDesc = "" + +const file_pluggableharness_event_v1_events_proto_rawDesc = "" + "\n" + - "%pluggableharness/event/v1/event.proto\x12\x19pluggableharness.event.v1\x1a'pluggableharness/common/v1/common.proto\x1a)pluggableharness/content/v1/content.proto\x1a#pluggableharness/hook/v1/hook.proto\x1a%pluggableharness/model/v1/model.proto\x1a#pluggableharness/plan/v1/plan.proto\x1a#pluggableharness/tool/v1/tool.proto\"\xe0\x01\n" + + "&pluggableharness/event/v1/events.proto\x12\x19pluggableharness.event.v1\x1a&pluggableharness/common/v1/types.proto\x1a'pluggableharness/content/v1/types.proto\x1a%pluggableharness/hook/v1/errors.proto\x1a%pluggableharness/model/v1/types.proto\x1a$pluggableharness/plan/v1/types.proto\x1a%pluggableharness/tool/v1/errors.proto\x1a$pluggableharness/tool/v1/types.proto\"\xe0\x01\n" + "\fMessageEvent\x12>\n" + "\amessage\x18\x01 \x01(\v2$.pluggableharness.content.v1.MessageR\amessage\x12=\n" + "\x05model\x18\x02 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\x05model\x126\n" + @@ -652,19 +652,19 @@ const file_pluggableharness_event_v1_event_proto_rawDesc = "" + "\x05error\x18\x01 \x01(\v2#.pluggableharness.hook.v1.HookErrorR\x05errorB>Z pluggableharness.content.v1.Message 10, // 1: pluggableharness.event.v1.MessageEvent.model:type_name -> pluggableharness.common.v1.ProducerRef 11, // 2: pluggableharness.event.v1.MessageEvent.usage:type_name -> pluggableharness.model.v1.Usage @@ -707,31 +707,31 @@ var file_pluggableharness_event_v1_event_proto_depIdxs = []int32{ 0, // [0:13] is the sub-list for field type_name } -func init() { file_pluggableharness_event_v1_event_proto_init() } -func file_pluggableharness_event_v1_event_proto_init() { - if File_pluggableharness_event_v1_event_proto != nil { +func init() { file_pluggableharness_event_v1_events_proto_init() } +func file_pluggableharness_event_v1_events_proto_init() { + if File_pluggableharness_event_v1_events_proto != nil { return } - file_pluggableharness_event_v1_event_proto_msgTypes[2].OneofWrappers = []any{ + file_pluggableharness_event_v1_events_proto_msgTypes[2].OneofWrappers = []any{ (*ToolResultEvent_Result)(nil), (*ToolResultEvent_Error)(nil), } - file_pluggableharness_event_v1_event_proto_msgTypes[5].OneofWrappers = []any{} + file_pluggableharness_event_v1_events_proto_msgTypes[5].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_event_v1_event_proto_rawDesc), len(file_pluggableharness_event_v1_event_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_event_v1_events_proto_rawDesc), len(file_pluggableharness_event_v1_events_proto_rawDesc)), NumEnums: 0, NumMessages: 9, NumExtensions: 0, NumServices: 0, }, - GoTypes: file_pluggableharness_event_v1_event_proto_goTypes, - DependencyIndexes: file_pluggableharness_event_v1_event_proto_depIdxs, - MessageInfos: file_pluggableharness_event_v1_event_proto_msgTypes, + GoTypes: file_pluggableharness_event_v1_events_proto_goTypes, + DependencyIndexes: file_pluggableharness_event_v1_events_proto_depIdxs, + MessageInfos: file_pluggableharness_event_v1_events_proto_msgTypes, }.Build() - File_pluggableharness_event_v1_event_proto = out.File - file_pluggableharness_event_v1_event_proto_goTypes = nil - file_pluggableharness_event_v1_event_proto_depIdxs = nil + File_pluggableharness_event_v1_events_proto = out.File + file_pluggableharness_event_v1_events_proto_goTypes = nil + file_pluggableharness_event_v1_events_proto_depIdxs = nil } diff --git a/pkg/frontend/attach.go b/pkg/frontend/attach.go new file mode 100644 index 0000000..6f1731d --- /dev/null +++ b/pkg/frontend/attach.go @@ -0,0 +1,135 @@ +package frontend + +import ( + "errors" + "io" + "sync" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" +) + +// Attach implements FrontendServiceServer's one bidirectional RPC — the +// single, connection-scoped, multiplexed event channel a frontend keeps +// open for its connection's whole lifetime (doc.go's "Attach is one stream +// per connection, not one per session"). This is a single dispatch loop, +// never one goroutine per session's own stream: every ClientEvent this +// connection receives, regardless of which session it names, is read from +// the same stream.Recv() call in arrival order and handed to +// svc.provider.HandleEvent, which answers through a connection-scoped +// Emitter shared by every session on this connection — so a session's +// backfill batch (SessionAttached, replayed Renders, BackfillComplete), +// like every other event, is written only to the one *connection this +// Attach call owns, never fanned out to any other connection +// (frontend-protocol.md's "Backfill is unicast to the attaching stream +// only, never broadcast"). +// +// See doc.go's "Wire direction" section for why this method RECEIVES +// ClientEvent and SENDS ServerEvent, the mechanical direction the +// generated frontendv1.FrontendServiceServer interface fixes. +func (svc *Service) Attach(stream frontendv1.FrontendService_AttachServer) error { + ctx := stream.Context() + conn := &connection{stream: stream} + + for { + in, err := stream.Recv() + if err != nil { + return terminal(err) + } + + event, convErr := fromClientEventProto(in) + if convErr != nil { + if sendErr := conn.emitError(nil, &Error{ + Category: frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT, + Message: convErr.Error(), + }); sendErr != nil { + return sendErr + } + continue + } + + if err := svc.provider.HandleEvent(ctx, event, conn); err != nil { + var fatal *FatalErr + if errors.As(err, &fatal) { + return fatal.Err + } + if sendErr := conn.emitError(requestIDOf(event), inBandError(err)); sendErr != nil { + return sendErr + } + } + } +} + +// terminal maps a stream.Recv error to what Attach itself should return: +// nil for ordinary stream closure (io.EOF, signaling the kernel called +// CloseSend) or expected cancellation (codes.Canceled — normal control +// flow per .claude/rules/grpc.md, never logged as a failure), or the error +// itself otherwise — a genuinely fatal transport condition that legitimately +// closes the stream with a gRPC status. +func terminal(err error) error { + if errors.Is(err, io.EOF) { + return nil + } + if status.Code(err) == codes.Canceled { + return nil + } + return err +} + +// connection adapts one Attach stream to the Emitter interface. mu guards +// Send, which grpc.ServerStream does not support calling concurrently from +// more than one goroutine — a real concern here, since a Provider may +// retain the Emitter past its own HandleEvent call to push an unsolicited +// ServerEvent from another goroutine while the dispatch loop above is +// concurrently emitting an in-band error for a later ClientEvent. +type connection struct { + stream frontendv1.FrontendService_AttachServer + mu sync.Mutex +} + +var _ Emitter = (*connection)(nil) + +// Emit sends event to the kernel over this connection's Attach stream. +func (c *connection) Emit(event ServerEvent) error { + out, convErr := toServerEventProto(event) + if convErr != nil { + return convErr + } + c.mu.Lock() + defer c.mu.Unlock() + return c.stream.Send(out) +} + +// emitError sends fe in-band as an ErrorEvent, correlated to requestID +// when non-nil — the mid-Attach error path (doc.go's "Error handling is +// two distinct paths, not one"). Its own Send failure is returned +// unwrapped so Attach's dispatch loop treats it exactly like any other +// broken-stream condition: fatal, closing the RPC with a gRPC status. +func (c *connection) emitError(requestID *string, fe *Error) error { + return c.Emit(ServerEvent{RequestID: requestID, Payload: ErrorEvent{Err: fe}}) +} + +// requestIDOf returns the request_id to correlate an in-band error back to +// the ClientEvent control message that triggered it +// (frontend-protocol.md's ServerEvent.request_id note), or nil for a +// session-scoped variant, which carries no request_id of its own. +func requestIDOf(event ClientEvent) *string { + switch p := event.Payload.(type) { + case CreateSession: + return strPtr(p.RequestID) + case AttachSession: + return strPtr(p.RequestID) + case ResumeSession: + return strPtr(p.RequestID) + case DetachSession: + return strPtr(p.RequestID) + case ListSessions: + return strPtr(p.RequestID) + default: + return nil + } +} + +func strPtr(s string) *string { return &s } diff --git a/pkg/frontend/attach_internal_test.go b/pkg/frontend/attach_internal_test.go new file mode 100644 index 0000000..0b5eb6b --- /dev/null +++ b/pkg/frontend/attach_internal_test.go @@ -0,0 +1,61 @@ +package frontend + +import ( + "errors" + "io" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestTerminal(t *testing.T) { + t.Parallel() + + if got := terminal(io.EOF); got != nil { + t.Errorf("terminal(io.EOF) = %v, want nil", got) + } + + canceled := status.Error(codes.Canceled, "client canceled") + if got := terminal(canceled); got != nil { + t.Errorf("terminal(Canceled) = %v, want nil", got) + } + + other := errors.New("transport broke") + if got := terminal(other); got != other { //nolint:errorlint // exact identity, not classification, is what's under test + t.Errorf("terminal(other) = %v, want %v", got, other) + } +} + +func TestRequestIDOf(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + event ClientEvent + want *string + }{ + {"create_session", ClientEvent{Payload: CreateSession{RequestID: "r1"}}, strPtr("r1")}, + {"attach_session", ClientEvent{Payload: AttachSession{RequestID: "r2"}}, strPtr("r2")}, + {"resume_session", ClientEvent{Payload: ResumeSession{RequestID: "r3"}}, strPtr("r3")}, + {"detach_session", ClientEvent{Payload: DetachSession{RequestID: "r4"}}, strPtr("r4")}, + {"list_sessions", ClientEvent{Payload: ListSessions{RequestID: "r5"}}, strPtr("r5")}, + {"user_message has no request_id", ClientEvent{Payload: UserMessage{}}, nil}, + {"hello has no request_id", ClientEvent{Payload: Hello{}}, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := requestIDOf(tt.event) + switch { + case got == nil && tt.want == nil: + case got == nil || tt.want == nil: + t.Errorf("requestIDOf() = %v, want %v", got, tt.want) + case *got != *tt.want: + t.Errorf("requestIDOf() = %q, want %q", *got, *tt.want) + } + }) + } +} diff --git a/pkg/frontend/attach_test.go b/pkg/frontend/attach_test.go new file mode 100644 index 0000000..f94d1fc --- /dev/null +++ b/pkg/frontend/attach_test.go @@ -0,0 +1,331 @@ +package frontend_test + +import ( + "context" + "errors" + "io" + "sync" + "testing" + "time" + + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + "github.com/pluggableharness/agent/pkg/frontend" + frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" +) + +// attachClient starts a Service wrapping provider and opens one Attach +// stream against it, mirroring how the kernel — the FrontendServiceClient +// for this RPC, per doc.go's "Wire direction" — would call in. +func attachClient(t *testing.T, provider frontend.Provider) frontendv1.FrontendService_AttachClient { + t.Helper() + + client := newTestServer(t, frontend.NewService(provider, testIdentity, nil)) + stream, err := client.Attach(t.Context()) + if err != nil { + t.Fatalf("Attach() error = %v", err) + } + return stream +} + +func userMessageEvent(sessionID, text string) *frontendv1.ClientEvent { + return &frontendv1.ClientEvent{ + SessionId: sessionID, + Event: &frontendv1.ClientEvent_UserMessage_{ + UserMessage: &frontendv1.ClientEvent_UserMessage{ + Content: []*contentv1.ContentBlock{{ + Block: &contentv1.ContentBlock_Text{Text: &contentv1.TextBlock{Text: text}}, + }}, + }, + }, + } +} + +func interruptEvent() *frontendv1.ClientEvent { + return &frontendv1.ClientEvent{ + SessionId: "sess-1", + Event: &frontendv1.ClientEvent_Interrupt_{Interrupt: &frontendv1.ClientEvent_Interrupt{}}, + } +} + +// TestAttach_SessionDemux sends ClientEvents for two different sessions +// interleaved on the one Attach stream and checks each is dispatched with +// its own session_id, and each reply is tagged back with the matching +// session_id — frontend-protocol.md's per-session multiplexing over one +// connection-scoped stream. +func TestAttach_SessionDemux(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + seen := map[string]int{} + + provider := &fakeProvider{ + handleEventFunc: func(_ context.Context, event frontend.ClientEvent, emit frontend.Emitter) error { + mu.Lock() + seen[event.SessionID]++ + mu.Unlock() + + um, ok := event.Payload.(frontend.UserMessage) + if !ok { + return nil + } + return emit.Emit(frontend.ServerEvent{ + SessionID: event.SessionID, + Payload: frontend.StreamDelta{TargetID: "t", Text: um.Content[0].GetText().GetText()}, + }) + }, + } + + stream := attachClient(t, provider) + + if err := stream.Send(userMessageEvent("sess-a", "hello-a")); err != nil { + t.Fatalf("Send(sess-a) error = %v", err) + } + if err := stream.Send(userMessageEvent("sess-b", "hello-b")); err != nil { + t.Fatalf("Send(sess-b) error = %v", err) + } + + gotA, gotB := false, false + for range 2 { + resp, err := stream.Recv() + if err != nil { + t.Fatalf("Recv() error = %v", err) + } + delta := resp.GetStreamDelta() + if delta == nil { + t.Fatalf("Recv() = %v, want stream_delta", resp) + } + switch resp.GetSessionId() { + case "sess-a": + if delta.GetText() != "hello-a" { + t.Errorf("sess-a delta text = %q, want hello-a", delta.GetText()) + } + gotA = true + case "sess-b": + if delta.GetText() != "hello-b" { + t.Errorf("sess-b delta text = %q, want hello-b", delta.GetText()) + } + gotB = true + default: + t.Errorf("unexpected session_id %q", resp.GetSessionId()) + } + } + if !gotA || !gotB { + t.Errorf("did not receive replies for both sessions: gotA=%v gotB=%v", gotA, gotB) + } + + mu.Lock() + defer mu.Unlock() + if seen["sess-a"] != 1 || seen["sess-b"] != 1 { + t.Errorf("HandleEvent call counts = %v, want 1 for each session", seen) + } +} + +// TestAttach_RequestIDCorrelation sends a CreateSession control event and +// checks the request_id the Provider echoes back arrives unchanged on the +// ServerEvent that answers it. +func TestAttach_RequestIDCorrelation(t *testing.T) { + t.Parallel() + + provider := &fakeProvider{ + handleEventFunc: func(_ context.Context, event frontend.ClientEvent, emit frontend.Emitter) error { + cs, ok := event.Payload.(frontend.CreateSession) + if !ok { + return nil + } + reqID := cs.RequestID + return emit.Emit(frontend.ServerEvent{ + SessionID: "new-sess", + RequestID: &reqID, + Payload: frontend.SessionCreated{}, + }) + }, + } + + stream := attachClient(t, provider) + + if err := stream.Send(&frontendv1.ClientEvent{ + Event: &frontendv1.ClientEvent_CreateSession_{ + CreateSession: &frontendv1.ClientEvent_CreateSession{RequestId: "req-42"}, + }, + }); err != nil { + t.Fatalf("Send() error = %v", err) + } + + resp, err := stream.Recv() + if err != nil { + t.Fatalf("Recv() error = %v", err) + } + if resp.GetSessionCreated() == nil { + t.Fatalf("Recv() = %v, want session_created", resp) + } + if resp.GetRequestId() != "req-42" { + t.Errorf("RequestId = %q, want req-42", resp.GetRequestId()) + } +} + +// TestAttach_InBandErrorKeepsStreamOpen checks that an ordinary error +// returned from HandleEvent surfaces as an in-band ServerEvent.error and +// the stream remains usable for subsequent events afterward — doc.go's +// "Error handling is two distinct paths, not one". +func TestAttach_InBandErrorKeepsStreamOpen(t *testing.T) { + t.Parallel() + + calls := 0 + provider := &fakeProvider{ + handleEventFunc: func(_ context.Context, event frontend.ClientEvent, emit frontend.Emitter) error { + calls++ + if calls == 1 { + return errors.New("recoverable failure") + } + return emit.Emit(frontend.ServerEvent{SessionID: event.SessionID, Payload: frontend.StreamDelta{Text: "ok"}}) + }, + } + + stream := attachClient(t, provider) + + if err := stream.Send(interruptEvent()); err != nil { + t.Fatalf("Send() (first) error = %v", err) + } + resp1, err := stream.Recv() + if err != nil { + t.Fatalf("Recv() (first) error = %v", err) + } + errEvent := resp1.GetError() + if errEvent == nil { + t.Fatalf("Recv() (first) = %v, want error", resp1) + } + if got := errEvent.GetError().GetCategory(); got != frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNKNOWN { + t.Errorf("category = %v, want UNKNOWN", got) + } + + // The stream stays open: a second event is still handled normally. + if err := stream.Send(interruptEvent()); err != nil { + t.Fatalf("Send() (second) error = %v", err) + } + resp2, err := stream.Recv() + if err != nil { + t.Fatalf("Recv() (second) error = %v", err) + } + if got := resp2.GetStreamDelta().GetText(); got != "ok" { + t.Errorf("second response = %q, want ok", got) + } +} + +// TestAttach_FatalClosesStream checks that frontend.Fatal from HandleEvent +// closes the Attach RPC with a gRPC status instead of reporting in-band. +func TestAttach_FatalClosesStream(t *testing.T) { + t.Parallel() + + provider := &fakeProvider{ + handleEventFunc: func(context.Context, frontend.ClientEvent, frontend.Emitter) error { + return frontend.Fatal(errors.New("plugin process is dying")) + }, + } + + stream := attachClient(t, provider) + + if err := stream.Send(interruptEvent()); err != nil { + t.Fatalf("Send() error = %v", err) + } + + _, err := stream.Recv() + if err == nil { + t.Fatalf("Recv() = nil error, want the stream to close after a Fatal HandleEvent") + } + if errors.Is(err, io.EOF) { + t.Errorf("Recv() = io.EOF, want a non-EOF error carrying the fatal condition") + } +} + +// TestAttach_InvalidClientEvent checks that a session-scoped ClientEvent +// arriving with an empty session_id is rejected in-band as +// FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT without ever reaching the +// Provider — frontend-protocol.md's error taxonomy. +func TestAttach_InvalidClientEvent(t *testing.T) { + t.Parallel() + + called := false + provider := &fakeProvider{ + handleEventFunc: func(context.Context, frontend.ClientEvent, frontend.Emitter) error { + called = true + return nil + }, + } + + stream := attachClient(t, provider) + + if err := stream.Send(&frontendv1.ClientEvent{ + Event: &frontendv1.ClientEvent_UserMessage_{UserMessage: &frontendv1.ClientEvent_UserMessage{}}, + }); err != nil { + t.Fatalf("Send() error = %v", err) + } + + resp, err := stream.Recv() + if err != nil { + t.Fatalf("Recv() error = %v", err) + } + errEvent := resp.GetError() + if errEvent == nil { + t.Fatalf("Recv() = %v, want error", resp) + } + if got := errEvent.GetError().GetCategory(); got != frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT { + t.Errorf("category = %v, want INVALID_CLIENT_EVENT", got) + } + if called { + t.Errorf("HandleEvent was called for a malformed ClientEvent; want it skipped") + } +} + +// TestAttach_UnicastNotBroadcast opens two independent Attach connections +// against the same Service and checks that a reply emitted on one +// connection is never observed on the other — frontend-protocol.md's +// "Backfill is unicast to the attaching stream only, never broadcast", +// generalized to this package's own connection-scoped Emitter: nothing in +// this SDK fans an emitted ServerEvent out beyond the *connection that +// produced it. +func TestAttach_UnicastNotBroadcast(t *testing.T) { + t.Parallel() + + provider := &fakeProvider{ + handleEventFunc: func(_ context.Context, event frontend.ClientEvent, emit frontend.Emitter) error { + return emit.Emit(frontend.ServerEvent{SessionID: event.SessionID, Payload: frontend.StreamDelta{Text: "reply"}}) + }, + } + + client := newTestServer(t, frontend.NewService(provider, testIdentity, nil)) + + streamA, err := client.Attach(t.Context()) + if err != nil { + t.Fatalf("Attach() (A) error = %v", err) + } + streamB, err := client.Attach(t.Context()) + if err != nil { + t.Fatalf("Attach() (B) error = %v", err) + } + + if err := streamA.Send(interruptEvent()); err != nil { + t.Fatalf("Send() error = %v", err) + } + + respA, err := streamA.Recv() + if err != nil { + t.Fatalf("Recv() (A) error = %v", err) + } + if got := respA.GetStreamDelta().GetText(); got != "reply" { + t.Fatalf("stream A reply = %q, want reply", got) + } + + // stream B must never observe A's reply. Bound the "nothing happened" + // wait with a short, overridable timeout rather than blocking forever. + recvB := make(chan struct{}) + go func() { + _, _ = streamB.Recv() + close(recvB) + }() + select { + case <-recvB: + t.Errorf("stream B received an event that was only ever emitted on stream A") + case <-time.After(150 * time.Millisecond): + // Expected: B never receives anything. + } +} diff --git a/pkg/frontend/capabilities.go b/pkg/frontend/capabilities.go new file mode 100644 index 0000000..bd83568 --- /dev/null +++ b/pkg/frontend/capabilities.go @@ -0,0 +1,58 @@ +package frontend + +import ( + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// CapabilitiesOption configures one optional field of a Capabilities built +// by NewCapabilities. +type CapabilitiesOption func(*Capabilities) + +// WithSlashCommands sets the prompt-expansion commands this frontend +// itself contributes. +func WithSlashCommands(commands ...*commonv1.PromptExpansionSpec) CapabilitiesOption { + return func(c *Capabilities) { c.SlashCommands = commands } +} + +// WithSupportedRegions sets the Regions this frontend proactively declares +// it can render into. +func WithSupportedRegions(regions ...renderv1.Region) CapabilitiesOption { + return func(c *Capabilities) { c.SupportedRegions = regions } +} + +// WithSupportedHookPoints sets the hook points this frontend can +// subscribe to. +func WithSupportedHookPoints(points ...commonv1.HookPoint) CapabilitiesOption { + return func(c *Capabilities) { c.SupportedHookPoints = points } +} + +// NewCapabilities builds a Capabilities from schema — typically assembled +// with github.com/pluggableharness/agent/pkg/config's Schema and Attribute +// — plus any options. schema MAY be nil for a provider with no +// configuration surface at all. +func NewCapabilities(schema *configv1.ConfigSchema, opts ...CapabilitiesOption) *Capabilities { + c := &Capabilities{ConfigSchema: schema} + for _, opt := range opts { + opt(c) + } + return c +} + +// capabilitiesToProto converts c into the generated response type +// GetCapabilities returns. A nil c converts to an empty +// FrontendCapabilities rather than a nil pointer, since +// GetCapabilitiesResponse.Capabilities is not itself optional on the wire. +func capabilitiesToProto(c *Capabilities) *frontendv1.FrontendCapabilities { + if c == nil { + return &frontendv1.FrontendCapabilities{} + } + return &frontendv1.FrontendCapabilities{ + SlashCommands: c.SlashCommands, + ConfigSchema: c.ConfigSchema, + SupportedRegions: c.SupportedRegions, + SupportedHookPoints: c.SupportedHookPoints, + } +} diff --git a/pkg/frontend/capabilities_test.go b/pkg/frontend/capabilities_test.go new file mode 100644 index 0000000..874d01c --- /dev/null +++ b/pkg/frontend/capabilities_test.go @@ -0,0 +1,60 @@ +package frontend_test + +import ( + "reflect" + "testing" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + "github.com/pluggableharness/agent/pkg/config" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + "github.com/pluggableharness/agent/pkg/frontend" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +func TestNewCapabilities(t *testing.T) { + t.Parallel() + + attr, err := config.Attribute("theme", configv1.AttrType_ATTR_TYPE_STRING) + if err != nil { + t.Fatalf("config.Attribute() error = %v", err) + } + schema, err := config.Schema(attr) + if err != nil { + t.Fatalf("config.Schema() error = %v", err) + } + + slash := &commonv1.PromptExpansionSpec{Name: "explain"} + + caps := frontend.NewCapabilities(schema, + frontend.WithSlashCommands(slash), + frontend.WithSupportedRegions(renderv1.Region_REGION_MAIN_CHAT, renderv1.Region_REGION_OVERLAY), + frontend.WithSupportedHookPoints(commonv1.HookPoint_HOOK_POINT_PRE_TOOL_CALL), + ) + + if caps.ConfigSchema != schema { + t.Errorf("ConfigSchema = %v, want %v", caps.ConfigSchema, schema) + } + if len(caps.SlashCommands) != 1 || caps.SlashCommands[0] != slash { + t.Errorf("SlashCommands = %v, want [%v]", caps.SlashCommands, slash) + } + wantRegions := []renderv1.Region{renderv1.Region_REGION_MAIN_CHAT, renderv1.Region_REGION_OVERLAY} + if !reflect.DeepEqual(caps.SupportedRegions, wantRegions) { + t.Errorf("SupportedRegions = %v, want %v", caps.SupportedRegions, wantRegions) + } + wantHooks := []commonv1.HookPoint{commonv1.HookPoint_HOOK_POINT_PRE_TOOL_CALL} + if !reflect.DeepEqual(caps.SupportedHookPoints, wantHooks) { + t.Errorf("SupportedHookPoints = %v, want %v", caps.SupportedHookPoints, wantHooks) + } +} + +func TestNewCapabilities_NilSchema(t *testing.T) { + t.Parallel() + + caps := frontend.NewCapabilities(nil) + if caps.ConfigSchema != nil { + t.Errorf("ConfigSchema = %v, want nil", caps.ConfigSchema) + } + if caps.SlashCommands != nil { + t.Errorf("SlashCommands = %v, want nil", caps.SlashCommands) + } +} diff --git a/pkg/frontend/convert.go b/pkg/frontend/convert.go new file mode 100644 index 0000000..a81c4a8 --- /dev/null +++ b/pkg/frontend/convert.go @@ -0,0 +1,421 @@ +package frontend + +import ( + "errors" + "fmt" + + frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" +) + +// Sentinel errors identifying which invariant a malformed ClientEvent or +// ServerEvent violated — compare with errors.Is. +var ( + // ErrMissingSessionID is returned by fromClientEventProto when a + // session-scoped variant (user_message..interrupt) arrives with an + // empty top-level session_id — frontend-protocol.md's error taxonomy + // names this FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT explicitly. + ErrMissingSessionID = errors.New("frontend: session-scoped client event missing session_id") + // ErrUnexpectedSessionID is returned by fromClientEventProto when a + // connection-level control variant (hello..list_sessions) arrives + // with a non-empty top-level session_id, which none of the six ever + // has a session to scope to before its own response arrives. + ErrUnexpectedSessionID = errors.New("frontend: control client event carries unexpected session_id") + // ErrEmptyClientEvent is returned by fromClientEventProto when the + // generated ClientEvent's oneof carries no variant at all. + ErrEmptyClientEvent = errors.New("frontend: client event carries no variant") + // ErrEmptyServerEvent is returned by toServerEventProto/ + // fromServerEventProto when a ServerEvent carries a nil Payload. + ErrEmptyServerEvent = errors.New("frontend: server event carries no payload") + // ErrNilFrontendError is returned when an ErrorEvent's Err field is + // nil — a caller-constructed ServerEvent that skipped the one field + // ErrorEvent exists to carry. + ErrNilFrontendError = errors.New("frontend: error event carries no Error") +) + +// fromClientEventProto converts in into its domain form, validating the +// session_id placement invariant frontend-protocol.md's ClientEvent +// section documents (REQUIRED for session-scoped variants, empty for the +// six connection-level control variants). +func fromClientEventProto(in *frontendv1.ClientEvent) (ClientEvent, error) { + sessionID := in.GetSessionId() + + switch ev := in.GetEvent().(type) { + case *frontendv1.ClientEvent_UserMessage_: + if sessionID == "" { + return ClientEvent{}, ErrMissingSessionID + } + return ClientEvent{SessionID: sessionID, Payload: UserMessage{ + Content: ev.UserMessage.GetContent(), + }}, nil + + case *frontendv1.ClientEvent_SlashCommand_: + if sessionID == "" { + return ClientEvent{}, ErrMissingSessionID + } + return ClientEvent{SessionID: sessionID, Payload: SlashCommand{ + Name: ev.SlashCommand.GetName(), + Args: ev.SlashCommand.GetArgs(), + }}, nil + + case *frontendv1.ClientEvent_PlanDecision_: + if sessionID == "" { + return ClientEvent{}, ErrMissingSessionID + } + pd := ev.PlanDecision + return ClientEvent{SessionID: sessionID, Payload: PlanDecision{ + PlanItemID: pd.GetPlanItemId(), + Decision: pd.GetDecision(), + CorrectedInput: pd.GetCorrectedInput(), + Scope: planScopeFromProto(pd.GetScope()), + }}, nil + + case *frontendv1.ClientEvent_InteractiveResponse_: + if sessionID == "" { + return ClientEvent{}, ErrMissingSessionID + } + ir := ev.InteractiveResponse + return ClientEvent{SessionID: sessionID, Payload: InteractiveResponse{ + CallID: ir.GetCallId(), + Response: ir.GetResponse(), + }}, nil + + case *frontendv1.ClientEvent_ActionTrigger_: + if sessionID == "" { + return ClientEvent{}, ErrMissingSessionID + } + at := ev.ActionTrigger + return ClientEvent{SessionID: sessionID, Payload: ActionTrigger{ + NodeID: at.GetNodeId(), + ToolName: at.GetToolName(), + Args: at.GetArgs(), + Provider: at.GetProvider(), + }}, nil + + case *frontendv1.ClientEvent_Interrupt_: + if sessionID == "" { + return ClientEvent{}, ErrMissingSessionID + } + return ClientEvent{SessionID: sessionID, Payload: Interrupt{}}, nil + + case *frontendv1.ClientEvent_Hello_: + if sessionID != "" { + return ClientEvent{}, ErrUnexpectedSessionID + } + return ClientEvent{Payload: Hello{ProtocolVersion: ev.Hello.GetProtocolVersion()}}, nil + + case *frontendv1.ClientEvent_CreateSession_: + if sessionID != "" { + return ClientEvent{}, ErrUnexpectedSessionID + } + cs := ev.CreateSession + return ClientEvent{Payload: CreateSession{ + RequestID: cs.GetRequestId(), + Profile: cs.Profile, + InitialPrompt: cs.InitialPrompt, + WorkingDirectory: cs.WorkingDirectory, + }}, nil + + case *frontendv1.ClientEvent_AttachSession_: + if sessionID != "" { + return ClientEvent{}, ErrUnexpectedSessionID + } + as := ev.AttachSession + return ClientEvent{Payload: AttachSession{ + RequestID: as.GetRequestId(), + SessionID: as.GetSessionId(), + }}, nil + + case *frontendv1.ClientEvent_ResumeSession_: + if sessionID != "" { + return ClientEvent{}, ErrUnexpectedSessionID + } + rs := ev.ResumeSession + return ClientEvent{Payload: ResumeSession{ + RequestID: rs.GetRequestId(), + SessionID: rs.GetSessionId(), + }}, nil + + case *frontendv1.ClientEvent_DetachSession_: + if sessionID != "" { + return ClientEvent{}, ErrUnexpectedSessionID + } + ds := ev.DetachSession + return ClientEvent{Payload: DetachSession{ + RequestID: ds.GetRequestId(), + SessionID: ds.GetSessionId(), + }}, nil + + case *frontendv1.ClientEvent_ListSessions_: + if sessionID != "" { + return ClientEvent{}, ErrUnexpectedSessionID + } + ls := ev.ListSessions + return ClientEvent{Payload: ListSessions{ + RequestID: ls.GetRequestId(), + Status: ls.Status, + ParentSessionID: ls.ParentSessionId, + RootsOnly: ls.GetRootsOnly(), + }}, nil + + default: + return ClientEvent{}, ErrEmptyClientEvent + } +} + +// toClientEventProto converts ev into its generated wire form. Provided +// for symmetry and so a test (in this package or a plugin author's own) +// can build a ClientEvent to send without importing frontendv1 directly. +func toClientEventProto(ev ClientEvent) (*frontendv1.ClientEvent, error) { + out := &frontendv1.ClientEvent{SessionId: ev.SessionID} + + switch p := ev.Payload.(type) { + case UserMessage: + out.Event = &frontendv1.ClientEvent_UserMessage_{ + UserMessage: &frontendv1.ClientEvent_UserMessage{Content: p.Content}, + } + case SlashCommand: + out.Event = &frontendv1.ClientEvent_SlashCommand_{ + SlashCommand: &frontendv1.ClientEvent_SlashCommand{Name: p.Name, Args: p.Args}, + } + case PlanDecision: + out.Event = &frontendv1.ClientEvent_PlanDecision_{ + PlanDecision: &frontendv1.ClientEvent_PlanDecision{ + PlanItemId: p.PlanItemID, + Decision: p.Decision, + CorrectedInput: p.CorrectedInput, + Scope: planScopeToProto(p.Scope), + }, + } + case InteractiveResponse: + out.Event = &frontendv1.ClientEvent_InteractiveResponse_{ + InteractiveResponse: &frontendv1.ClientEvent_InteractiveResponse{ + CallId: p.CallID, + Response: p.Response, + }, + } + case ActionTrigger: + out.Event = &frontendv1.ClientEvent_ActionTrigger_{ + ActionTrigger: &frontendv1.ClientEvent_ActionTrigger{ + NodeId: p.NodeID, + ToolName: p.ToolName, + Args: p.Args, + Provider: p.Provider, + }, + } + case Interrupt: + out.Event = &frontendv1.ClientEvent_Interrupt_{Interrupt: &frontendv1.ClientEvent_Interrupt{}} + case Hello: + out.Event = &frontendv1.ClientEvent_Hello_{ + Hello: &frontendv1.ClientEvent_Hello{ProtocolVersion: p.ProtocolVersion}, + } + case CreateSession: + out.Event = &frontendv1.ClientEvent_CreateSession_{ + CreateSession: &frontendv1.ClientEvent_CreateSession{ + RequestId: p.RequestID, + Profile: p.Profile, + InitialPrompt: p.InitialPrompt, + WorkingDirectory: p.WorkingDirectory, + }, + } + case AttachSession: + out.Event = &frontendv1.ClientEvent_AttachSession_{ + AttachSession: &frontendv1.ClientEvent_AttachSession{RequestId: p.RequestID, SessionId: p.SessionID}, + } + case ResumeSession: + out.Event = &frontendv1.ClientEvent_ResumeSession_{ + ResumeSession: &frontendv1.ClientEvent_ResumeSession{RequestId: p.RequestID, SessionId: p.SessionID}, + } + case DetachSession: + out.Event = &frontendv1.ClientEvent_DetachSession_{ + DetachSession: &frontendv1.ClientEvent_DetachSession{RequestId: p.RequestID, SessionId: p.SessionID}, + } + case ListSessions: + out.Event = &frontendv1.ClientEvent_ListSessions_{ + ListSessions: &frontendv1.ClientEvent_ListSessions{ + RequestId: p.RequestID, + Status: p.Status, + ParentSessionId: p.ParentSessionID, + RootsOnly: p.RootsOnly, + }, + } + default: + return nil, fmt.Errorf("frontend: to client event proto: %w", ErrEmptyClientEvent) + } + + return out, nil +} + +// fromServerEventProto converts in into its domain form. +func fromServerEventProto(in *frontendv1.ServerEvent) (ServerEvent, error) { + out := ServerEvent{SessionID: in.GetSessionId(), RequestID: in.RequestId} + + switch ev := in.GetEvent().(type) { + case *frontendv1.ServerEvent_StreamDelta_: + out.Payload = StreamDelta{TargetID: ev.StreamDelta.GetTargetId(), Text: ev.StreamDelta.GetText()} + case *frontendv1.ServerEvent_Render_: + out.Payload = Render{Content: ev.Render.GetContent()} + case *frontendv1.ServerEvent_PermissionRequest_: + out.Payload = PermissionRequest{PlanItem: ev.PermissionRequest.GetPlanItem()} + case *frontendv1.ServerEvent_PlanReady_: + out.Payload = PlanReady{Plan: ev.PlanReady.GetPlan()} + case *frontendv1.ServerEvent_InteractiveRequest_: + out.Payload = InteractiveRequest{ + CallID: ev.InteractiveRequest.GetCallId(), + ToolName: ev.InteractiveRequest.GetToolName(), + Prompt: ev.InteractiveRequest.GetPrompt(), + } + case *frontendv1.ServerEvent_SessionTreeUpdate_: + out.Payload = SessionTreeUpdate{ + ParentSessionID: ev.SessionTreeUpdate.GetParentSessionId(), + ChildSessionID: ev.SessionTreeUpdate.GetChildSessionId(), + Status: ev.SessionTreeUpdate.GetStatus(), + } + case *frontendv1.ServerEvent_Error_: + fe := ev.Error.GetError() + out.Payload = ErrorEvent{Err: &Error{Category: fe.GetCategory(), Message: fe.GetMessage()}} + case *frontendv1.ServerEvent_SessionCreated_: + out.Payload = SessionCreated{Info: ev.SessionCreated.GetInfo()} + case *frontendv1.ServerEvent_SessionAttached_: + out.Payload = SessionAttached{Info: ev.SessionAttached.GetInfo()} + case *frontendv1.ServerEvent_BackfillComplete_: + out.Payload = BackfillComplete{LastSequence: ev.BackfillComplete.GetLastSequence()} + case *frontendv1.ServerEvent_SessionDetached_: + out.Payload = SessionDetached{} + case *frontendv1.ServerEvent_SessionList_: + out.Payload = SessionList{Sessions: ev.SessionList.GetSessions()} + case *frontendv1.ServerEvent_SlashCommandRegistry_: + out.Payload = SlashCommandRegistry{ + DirectInvokeCommands: ev.SlashCommandRegistry.GetDirectInvokeCommands(), + PromptExpansionCommands: ev.SlashCommandRegistry.GetPromptExpansionCommands(), + } + case *frontendv1.ServerEvent_UsageUpdate_: + out.Payload = UsageUpdate{ + Turn: ev.UsageUpdate.GetTurn(), + CumulativeCostUSD: ev.UsageUpdate.GetCumulativeCostUsd(), + UsedTokens: ev.UsageUpdate.GetUsedTokens(), + EffectiveCeiling: ev.UsageUpdate.GetEffectiveCeiling(), + } + case *frontendv1.ServerEvent_SessionStatusUpdate_: + out.Payload = SessionStatusUpdate{Status: ev.SessionStatusUpdate.GetStatus()} + default: + return ServerEvent{}, ErrEmptyServerEvent + } + + return out, nil +} + +// toServerEventProto converts ev into its generated wire form. +func toServerEventProto(ev ServerEvent) (*frontendv1.ServerEvent, error) { + out := &frontendv1.ServerEvent{SessionId: ev.SessionID, RequestId: ev.RequestID} + + switch p := ev.Payload.(type) { + case StreamDelta: + out.Event = &frontendv1.ServerEvent_StreamDelta_{ + StreamDelta: &frontendv1.ServerEvent_StreamDelta{TargetId: p.TargetID, Text: p.Text}, + } + case Render: + out.Event = &frontendv1.ServerEvent_Render_{Render: &frontendv1.ServerEvent_Render{Content: p.Content}} + case PermissionRequest: + out.Event = &frontendv1.ServerEvent_PermissionRequest_{ + PermissionRequest: &frontendv1.ServerEvent_PermissionRequest{PlanItem: p.PlanItem}, + } + case PlanReady: + out.Event = &frontendv1.ServerEvent_PlanReady_{PlanReady: &frontendv1.ServerEvent_PlanReady{Plan: p.Plan}} + case InteractiveRequest: + out.Event = &frontendv1.ServerEvent_InteractiveRequest_{ + InteractiveRequest: &frontendv1.ServerEvent_InteractiveRequest{ + CallId: p.CallID, + ToolName: p.ToolName, + Prompt: p.Prompt, + }, + } + case SessionTreeUpdate: + out.Event = &frontendv1.ServerEvent_SessionTreeUpdate_{ + SessionTreeUpdate: &frontendv1.ServerEvent_SessionTreeUpdate{ + ParentSessionId: p.ParentSessionID, + ChildSessionId: p.ChildSessionID, + Status: p.Status, + }, + } + case ErrorEvent: + if p.Err == nil { + return nil, ErrNilFrontendError + } + out.Event = &frontendv1.ServerEvent_Error_{ + Error: &frontendv1.ServerEvent_Error{ + Error: &frontendv1.FrontendError{Category: p.Err.Category, Message: p.Err.Message}, + }, + } + case SessionCreated: + out.Event = &frontendv1.ServerEvent_SessionCreated_{ + SessionCreated: &frontendv1.ServerEvent_SessionCreated{Info: p.Info}, + } + case SessionAttached: + out.Event = &frontendv1.ServerEvent_SessionAttached_{ + SessionAttached: &frontendv1.ServerEvent_SessionAttached{Info: p.Info}, + } + case BackfillComplete: + out.Event = &frontendv1.ServerEvent_BackfillComplete_{ + BackfillComplete: &frontendv1.ServerEvent_BackfillComplete{LastSequence: p.LastSequence}, + } + case SessionDetached: + out.Event = &frontendv1.ServerEvent_SessionDetached_{SessionDetached: &frontendv1.ServerEvent_SessionDetached{}} + case SessionList: + out.Event = &frontendv1.ServerEvent_SessionList_{ + SessionList: &frontendv1.ServerEvent_SessionList{Sessions: p.Sessions}, + } + case SlashCommandRegistry: + out.Event = &frontendv1.ServerEvent_SlashCommandRegistry_{ + SlashCommandRegistry: &frontendv1.ServerEvent_SlashCommandRegistry{ + DirectInvokeCommands: p.DirectInvokeCommands, + PromptExpansionCommands: p.PromptExpansionCommands, + }, + } + case UsageUpdate: + out.Event = &frontendv1.ServerEvent_UsageUpdate_{ + UsageUpdate: &frontendv1.ServerEvent_UsageUpdate{ + Turn: p.Turn, + CumulativeCostUsd: p.CumulativeCostUSD, + UsedTokens: p.UsedTokens, + EffectiveCeiling: p.EffectiveCeiling, + }, + } + case SessionStatusUpdate: + out.Event = &frontendv1.ServerEvent_SessionStatusUpdate_{ + SessionStatusUpdate: &frontendv1.ServerEvent_SessionStatusUpdate{Status: p.Status}, + } + default: + return nil, ErrEmptyServerEvent + } + + return out, nil +} + +// planScopeFromProto converts a wire PlanDecisionScope to its domain +// PlanScope, mapping both PLAN_DECISION_SCOPE_UNSPECIFIED and +// PLAN_DECISION_SCOPE_ONCE to PlanScopeOnce — see PlanScope's doc comment. +func planScopeFromProto(s frontendv1.PlanDecisionScope) PlanScope { + switch s { + case frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_SESSION: + return PlanScopeSession + case frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ALWAYS: + return PlanScopeAlways + default: + return PlanScopeOnce + } +} + +// planScopeToProto converts a domain PlanScope to its wire +// PlanDecisionScope. Never produces PLAN_DECISION_SCOPE_UNSPECIFIED — the +// domain type's zero value, PlanScopeOnce, already carries the +// spec-mandated default. +func planScopeToProto(s PlanScope) frontendv1.PlanDecisionScope { + switch s { + case PlanScopeSession: + return frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_SESSION + case PlanScopeAlways: + return frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ALWAYS + default: + return frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE + } +} diff --git a/pkg/frontend/convert_test.go b/pkg/frontend/convert_test.go new file mode 100644 index 0000000..70fa9fd --- /dev/null +++ b/pkg/frontend/convert_test.go @@ -0,0 +1,262 @@ +package frontend + +import ( + "errors" + "reflect" + "testing" + + "google.golang.org/protobuf/types/known/structpb" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" + sessionv1 "github.com/pluggableharness/agent/pkg/session/proto/v1" + slashcommandv1 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" +) + +func strp(s string) *string { return &s } + +func TestClientEventRoundTrip(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + event ClientEvent + }{ + {"user_message", ClientEvent{SessionID: "sess-1", Payload: UserMessage{ + Content: []*contentv1.ContentBlock{{Block: &contentv1.ContentBlock_Text{Text: &contentv1.TextBlock{Text: "hi"}}}}, + }}}, + {"slash_command", ClientEvent{SessionID: "sess-1", Payload: SlashCommand{Name: "help", Args: "me"}}}, + {"plan_decision", ClientEvent{SessionID: "sess-1", Payload: PlanDecision{ + PlanItemID: "item-1", + Decision: frontendv1.ClientDecision_CLIENT_DECISION_ALLOW, + CorrectedInput: &structpb.Struct{}, + Scope: PlanScopeSession, + }}}, + {"interactive_response", ClientEvent{SessionID: "sess-1", Payload: InteractiveResponse{ + CallID: "call-1", Response: &structpb.Struct{}, + }}}, + {"action_trigger", ClientEvent{SessionID: "sess-1", Payload: ActionTrigger{ + NodeID: "node-1", ToolName: "grep", Args: &structpb.Struct{}, Provider: "ripgrep", + }}}, + {"interrupt", ClientEvent{SessionID: "sess-1", Payload: Interrupt{}}}, + {"hello", ClientEvent{Payload: Hello{ProtocolVersion: 3}}}, + {"create_session", ClientEvent{Payload: CreateSession{ + RequestID: "req-1", Profile: strp("default"), InitialPrompt: strp("hi"), WorkingDirectory: strp("/tmp"), + }}}, + {"attach_session", ClientEvent{Payload: AttachSession{RequestID: "req-2", SessionID: "sess-2"}}}, + {"resume_session", ClientEvent{Payload: ResumeSession{RequestID: "req-3", SessionID: "sess-3"}}}, + {"detach_session", ClientEvent{Payload: DetachSession{RequestID: "req-4", SessionID: "sess-4"}}}, + {"list_sessions", ClientEvent{Payload: ListSessions{ + RequestID: "req-5", ParentSessionID: strp("sess-0"), RootsOnly: true, + }}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + proto, err := toClientEventProto(tt.event) + if err != nil { + t.Fatalf("toClientEventProto() error = %v", err) + } + got, err := fromClientEventProto(proto) + if err != nil { + t.Fatalf("fromClientEventProto() error = %v", err) + } + if got.SessionID != tt.event.SessionID { + t.Errorf("SessionID = %q, want %q", got.SessionID, tt.event.SessionID) + } + if !reflect.DeepEqual(got.Payload, tt.event.Payload) { + t.Errorf("Payload = %#v, want %#v", got.Payload, tt.event.Payload) + } + }) + } +} + +func TestClientEvent_SessionIDValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in *frontendv1.ClientEvent + wantErr error + }{ + { + name: "session-scoped missing session_id", + in: &frontendv1.ClientEvent{ + Event: &frontendv1.ClientEvent_UserMessage_{UserMessage: &frontendv1.ClientEvent_UserMessage{}}, + }, + wantErr: ErrMissingSessionID, + }, + { + name: "control variant with unexpected session_id", + in: &frontendv1.ClientEvent{ + SessionId: "sess-1", + Event: &frontendv1.ClientEvent_Hello_{Hello: &frontendv1.ClientEvent_Hello{}}, + }, + wantErr: ErrUnexpectedSessionID, + }, + { + name: "empty oneof", + in: &frontendv1.ClientEvent{}, + wantErr: ErrEmptyClientEvent, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := fromClientEventProto(tt.in) + if !errors.Is(err, tt.wantErr) { + t.Errorf("fromClientEventProto() error = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestToClientEventProto_EmptyPayload(t *testing.T) { + t.Parallel() + + _, err := toClientEventProto(ClientEvent{}) + if !errors.Is(err, ErrEmptyClientEvent) { + t.Errorf("toClientEventProto(empty) error = %v, want %v", err, ErrEmptyClientEvent) + } +} + +func TestServerEventRoundTrip(t *testing.T) { + t.Parallel() + + requestID := strp("req-9") + + tests := []struct { + name string + event ServerEvent + }{ + {"stream_delta", ServerEvent{SessionID: "s1", Payload: StreamDelta{TargetID: "t1", Text: "chunk"}}}, + {"render", ServerEvent{SessionID: "s1", Payload: Render{Content: &renderv1.PlacedContent{Region: renderv1.Region_REGION_MAIN_CHAT}}}}, + {"permission_request", ServerEvent{SessionID: "s1", Payload: PermissionRequest{}}}, + {"plan_ready", ServerEvent{SessionID: "s1", Payload: PlanReady{}}}, + {"interactive_request", ServerEvent{SessionID: "s1", Payload: InteractiveRequest{CallID: "c1", ToolName: "ask"}}}, + {"session_tree_update", ServerEvent{SessionID: "s1", Payload: SessionTreeUpdate{ + ParentSessionID: "p1", ChildSessionID: "c1", Status: sessionv1.SessionStatus_SESSION_STATUS_RUNNING, + }}}, + {"error", ServerEvent{SessionID: "s1", RequestID: requestID, Payload: ErrorEvent{ + Err: &Error{Category: frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_RENDER_FAILED, Message: "bad diff"}, + }}}, + {"session_created", ServerEvent{SessionID: "s1", RequestID: requestID, Payload: SessionCreated{Info: &sessionv1.SessionInfo{SessionId: "s1"}}}}, + {"session_attached", ServerEvent{SessionID: "s1", RequestID: requestID, Payload: SessionAttached{Info: &sessionv1.SessionInfo{SessionId: "s1"}}}}, + {"backfill_complete", ServerEvent{SessionID: "s1", RequestID: requestID, Payload: BackfillComplete{LastSequence: 42}}}, + {"session_detached", ServerEvent{SessionID: "s1", RequestID: requestID, Payload: SessionDetached{}}}, + {"session_list", ServerEvent{RequestID: requestID, Payload: SessionList{Sessions: []*sessionv1.SessionInfo{{SessionId: "s1"}}}}}, + {"slash_command_registry", ServerEvent{SessionID: "s1", Payload: SlashCommandRegistry{ + DirectInvokeCommands: []*slashcommandv1.SlashCommandSpec{{Name: "run"}}, + PromptExpansionCommands: []*commonv1.PromptExpansionSpec{{Name: "help"}}, + }}}, + {"usage_update", ServerEvent{SessionID: "s1", Payload: UsageUpdate{ + CumulativeCostUSD: 1.5, UsedTokens: 100, EffectiveCeiling: 200, + }}}, + {"session_status_update", ServerEvent{SessionID: "s1", Payload: SessionStatusUpdate{Status: sessionv1.SessionStatus_SESSION_STATUS_COMPLETED}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + proto, err := toServerEventProto(tt.event) + if err != nil { + t.Fatalf("toServerEventProto() error = %v", err) + } + got, err := fromServerEventProto(proto) + if err != nil { + t.Fatalf("fromServerEventProto() error = %v", err) + } + if got.SessionID != tt.event.SessionID { + t.Errorf("SessionID = %q, want %q", got.SessionID, tt.event.SessionID) + } + gotReq, wantReq := got.RequestID, tt.event.RequestID + switch { + case gotReq == nil && wantReq == nil: + case gotReq == nil || wantReq == nil: + t.Errorf("RequestID = %v, want %v", gotReq, wantReq) + case *gotReq != *wantReq: + t.Errorf("RequestID = %q, want %q", *gotReq, *wantReq) + } + }) + } +} + +func TestToServerEventProto_EmptyPayload(t *testing.T) { + t.Parallel() + + _, err := toServerEventProto(ServerEvent{}) + if !errors.Is(err, ErrEmptyServerEvent) { + t.Errorf("toServerEventProto(empty) error = %v, want %v", err, ErrEmptyServerEvent) + } +} + +func TestToServerEventProto_NilFrontendError(t *testing.T) { + t.Parallel() + + _, err := toServerEventProto(ServerEvent{Payload: ErrorEvent{}}) + if !errors.Is(err, ErrNilFrontendError) { + t.Errorf("toServerEventProto(ErrorEvent{}) error = %v, want %v", err, ErrNilFrontendError) + } +} + +func TestFromServerEventProto_EmptyOneof(t *testing.T) { + t.Parallel() + + _, err := fromServerEventProto(&frontendv1.ServerEvent{}) + if !errors.Is(err, ErrEmptyServerEvent) { + t.Errorf("fromServerEventProto(empty) error = %v, want %v", err, ErrEmptyServerEvent) + } +} + +func TestPlanScopeConversion(t *testing.T) { + t.Parallel() + + tests := []struct { + domain PlanScope + proto frontendv1.PlanDecisionScope + }{ + {PlanScopeOnce, frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE}, + {PlanScopeSession, frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_SESSION}, + {PlanScopeAlways, frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ALWAYS}, + } + for _, tt := range tests { + if got := planScopeToProto(tt.domain); got != tt.proto { + t.Errorf("planScopeToProto(%v) = %v, want %v", tt.domain, got, tt.proto) + } + if got := planScopeFromProto(tt.proto); got != tt.domain { + t.Errorf("planScopeFromProto(%v) = %v, want %v", tt.proto, got, tt.domain) + } + } + + // The generated enum's own zero value, UNSPECIFIED, maps to + // PlanScopeOnce too — doc.go's "PlanDecisionScope defaults to ONCE". + if got := planScopeFromProto(frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_UNSPECIFIED); got != PlanScopeOnce { + t.Errorf("planScopeFromProto(UNSPECIFIED) = %v, want PlanScopeOnce", got) + } + if PlanScopeOnce != 0 { + t.Errorf("PlanScopeOnce = %d, want 0 (the Go zero value)", PlanScopeOnce) + } +} + +func TestPlanScope_String(t *testing.T) { + t.Parallel() + + tests := map[PlanScope]string{ + PlanScopeOnce: "once", + PlanScopeSession: "session", + PlanScopeAlways: "always", + PlanScope(99): "unknown", + } + for scope, want := range tests { + if got := scope.String(); got != want { + t.Errorf("PlanScope(%d).String() = %q, want %q", scope, got, want) + } + } +} diff --git a/pkg/frontend/doc.go b/pkg/frontend/doc.go new file mode 100644 index 0000000..b2b585a --- /dev/null +++ b/pkg/frontend/doc.go @@ -0,0 +1,123 @@ +// Package frontend is the hand-written, ergonomic SDK layer over the +// generated pluggableharness.frontend.v1 types in ./proto/v1, for a plugin +// author implementing a frontend provider — the plugin that owns the +// terminal (or window, or voice channel) and mediates between an operator +// and the kernel. The protocol this package implements is specified in +// docs/specifications/frontend/frontend-protocol.md, +// docs/specifications/frontend/render-tree.md (the RenderTree a frontend +// MUST be able to paint, including a node variant added after this +// package's own build — see FallbackText), and +// docs/specifications/frontend/conformance.md. +// +// # Shape +// +// A plugin author implements [Provider] — GetCapabilities, Configure, and +// event handling for the one connection-scoped, bidirectional Attach +// stream — and passes it to [NewService], which adapts it into the +// generated frontendv1.FrontendServiceServer and satisfies +// github.com/pluggableharness/agent/pkg/plugin's Service interface for use +// with plugin.Config.Services. +// +// # Attach is one stream per connection, not one per session +// +// Per frontend-protocol.md's "Transport" section, a frontend opens exactly +// one Attach stream for its connection's whole lifetime; individual +// sessions are subscribed and unsubscribed onto that single stream via the +// six session-control ClientEvent variants (hello, create_session, +// attach_session, resume_session, detach_session, list_sessions), +// correlated to their ServerEvent acknowledgments by a client-generated +// request_id. Every event on the wire, both directions, carries a +// top-level session_id that multiplexes which session it belongs to — +// [ClientEvent] and [ServerEvent] both carry SessionID as a first-class +// field for exactly this reason. This package's Attach adapter +// (attach.go) is a single per-connection dispatch loop, never one +// goroutine per session's own stream, and never fans a session's backfill +// batch out to any connection other than the one that requested it. +// +// # Wire direction: this package receives ClientEvent, sends ServerEvent +// +// The generated frontendv1.FrontendServiceServer.Attach signature — +// Attach(grpc.BidiStreamingServer[ClientEvent, ServerEvent]) error — fixes +// the mechanical wire direction for whichever process implements it: per +// architecture.md's "Transport" section ("category-client construction" is +// a kernel-side launch step for every plugin category, no exception for +// frontend) and github.com/pluggableharness/agent/pkg/plugin's +// Serve/Config (a plugin subprocess only ever runs as a gRPC server — +// its GRPCClient path is unconditionally unsupported), the kernel is the +// FrontendServiceClient that calls Attach, and this package's Service, +// registered as a plugin.Service on the frontend subprocess's own +// grpc.Server, is the FrontendServiceServer. Mechanically that means this +// package's Attach implementation RECEIVES *ClientEvent via stream.Recv() +// and SENDS *ServerEvent via stream.Send() — the reverse of +// frontend-protocol.md's plain-English framing ("the plugin ... sends +// operator input to the kernel as ClientEvents ... and receives ... +// ServerEvents"), which describes the logical origin/destination of each +// event's content rather than which side of this specific bidirectional +// RPC calls Send versus Recv. [Provider.HandleEvent] and [Emitter] are +// built around the actual, compiling mechanical direction: HandleEvent is +// invoked once per ClientEvent this adapter receives, and a Provider +// answers via the Emitter's Emit method, which sends a ServerEvent. +// +// # Fast path vs. full render +// +// [StreamDelta] and [Render] are deliberately distinct Go types (not a +// single "text update" type with a live/replayed flag) per +// frontend-protocol.md's "Fast path vs. full render" section: live +// token-by-token text streaming arrives only as StreamDelta and is never +// replayed as one on backfill, while a finished render — live or replayed +// — always arrives as Render, never as a sequence of deltas. Keeping them +// structurally separate in the [ServerEventPayload] oneof means an +// author's dispatch code cannot accidentally treat a backfilled Render as +// a live StreamDelta or vice versa. +// +// # PlanDecisionScope defaults to ONCE +// +// The generated PlanDecisionScope enum's zero value is +// PLAN_DECISION_SCOPE_UNSPECIFIED, a wire state that means "the sender +// forgot to set this." Per frontend-protocol.md's +// "plan_decision.corrected_input" section, PLAN_DECISION_SCOPE_ONCE is the +// default a frontend SHOULD send absent explicit operator intent — so this +// package's own [PlanScope] domain type reorders the values so its Go +// zero value is PlanScopeOnce, letting a zero-value [PlanDecision] already +// carry the spec-mandated default rather than an invalid UNSPECIFIED. +// +// # Error handling is two distinct paths, not one +// +// A Configure-time error surfaces as a gRPC status carrying a +// [Error] in structured detail, built via +// github.com/pluggableharness/agent/pkg/plugin's StatusError — see +// [Error.StatusErr]. An error encountered mid-Attach (a bad +// render, a malformed ClientEvent, a Provider.HandleEvent failure) instead +// surfaces in-band as ServerEvent.error, keeping the long-lived stream +// open, since tearing down the whole connection over one recoverable +// error would be far more disruptive than the single event it invalidated +// — this is the path an author's HandleEvent naturally reaches by simply +// returning an ordinary error. Only a genuinely fatal condition — the +// plugin process itself failing — legitimately closes the stream with a +// gRPC status, and doing so requires the deliberate, differently-named +// [Fatal] wrapper (see attach.go and errors.go). Conflating these two +// paths — closing the stream over an ordinary recoverable error, or +// silently swallowing a fatal one in-band — is the single most common way +// to get this package's contract wrong. +// +// # ContentBlocks, not a bare string +// +// [UserMessage] carries the same repeated content.v1.ContentBlock +// vocabulary as everywhere else in this protocol series, never a plain +// string — see github.com/pluggableharness/agent/pkg/content's Text, +// Image, Document, and other builders. Field 1 of the generated +// ClientEvent_UserMessage (the protocol's original bare text field) is +// reserved and MUST NOT be reused. +// +// # Author-side UI discipline this package cannot enforce in Go +// +// Two MUST-level rules bind a conforming frontend's own UI code, not +// anything this SDK can check at compile time or runtime, so they are +// documented prominently here instead: an [InteractiveRequest]'s Prompt +// MUST be rendered in the REGION_OVERLAY region, the same visual treatment +// as an ordinary plan-apply-gate "ask" prompt +// (render-tree.md#placement--regions); and a rendered ActionNode MUST be +// made interactive, dispatching that node's tool_name/args/provider +// unchanged as an [ActionTrigger] on activation, never rewritten +// (render-tree.md#interactive-content-the-action-node). +package frontend diff --git a/pkg/frontend/errors.go b/pkg/frontend/errors.go new file mode 100644 index 0000000..a9c51e4 --- /dev/null +++ b/pkg/frontend/errors.go @@ -0,0 +1,139 @@ +package frontend + +import ( + "errors" + "fmt" + + "google.golang.org/grpc/codes" + + frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" +) + +// errorDomain is the google.rpc.ErrorInfo domain passed to +// plugin.StatusError for every Error this package surfaces as a +// gRPC status, per .claude/rules/grpc.md's error taxonomy. +const errorDomain = "frontend.pluggableharness.dev" + +// Error is the domain form of frontendv1.FrontendError — the +// structured error type for this category, carried in ServerEvent.error +// mid-Attach and in the structured detail of a Configure-time gRPC status +// (doc.go's "Error handling is two distinct paths, not one"). +// +// The ten FrontendErrorCategory values, and this package's mapping to +// grpc/codes.Code (used only for the Configure-time gRPC-status path; +// mid-Attach errors carry the category in-band and never touch a +// grpc/codes.Code at all): +// +// FRONTEND_ERROR_CATEGORY_UNSPECIFIED codes.Internal (never a valid category to send; treated as an internal bug) +// FRONTEND_ERROR_CATEGORY_RENDER_FAILED codes.Internal (a RenderTree/PlacedContent could not be painted; reported in-band in the ordinary case, never expected at Configure time) +// FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT codes.InvalidArgument (malformed input on the operator-facing side) +// FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED codes.FailedPrecondition (this frontend has no fallback behavior at all for the targeted Region) +// FRONTEND_ERROR_CATEGORY_UNKNOWN codes.Internal (anything else; never codes.Unknown, per .claude/rules/grpc.md) +// FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND codes.NotFound (attach/resume/detach/list named a session_id the kernel has no record of) +// FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED codes.InvalidArgument (create_session failed: an invalid profile or unusable working_directory) +// FRONTEND_ERROR_CATEGORY_SESSION_BUSY codes.FailedPrecondition (reserved; no variant in this protocol revision currently triggers it) +// FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW codes.FailedPrecondition (resume_session named a session file newer than this kernel understands) +// FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY codes.FailedPrecondition (a new-turn-inducing event targeted a session attached replay-only) +type Error struct { + Category frontendv1.FrontendErrorCategory + Message string +} + +// Error implements the error interface. +func (e *Error) Error() string { + return fmt.Sprintf("frontend: %s: %s", e.Category, e.Message) +} + +// grpcCode maps e.Category to the grpc/codes.Code a Configure-time status +// built from e carries, per the table on Error's own doc comment. +func (e *Error) grpcCode() codes.Code { + switch e.Category { + case frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT, + frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED: + return codes.InvalidArgument + case frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND: + return codes.NotFound + case frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED, + frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_BUSY, + frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW, + frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY: + return codes.FailedPrecondition + default: + // FRONTEND_ERROR_CATEGORY_UNSPECIFIED, FRONTEND_ERROR_CATEGORY_UNKNOWN, + // FRONTEND_ERROR_CATEGORY_RENDER_FAILED, and any future category this + // build predates: codes.Internal, never codes.Unknown + // (.claude/rules/grpc.md's error taxonomy). + return codes.Internal + } +} + +// StatusErr builds the gRPC status a Configure-time e surfaces as, per +// frontend-protocol.md's "ConfigureResponse errors surface as a gRPC +// status carrying a Error in its structured detail." +func (e *Error) StatusErr() error { + return plugin.StatusError(e.grpcCode(), errorDomain, e.Category.String(), e.Message, nil) +} + +// statusErr converts an arbitrary error returned by Provider.Capabilities +// or Provider.Configure into the gRPC status NewService's unary handlers +// return: err's own Error when it carries one, or a generic +// FRONTEND_ERROR_CATEGORY_UNKNOWN status otherwise. +func statusErr(err error) error { + var fe *Error + if errors.As(err, &fe) { + return fe.StatusErr() + } + return (&Error{ + Category: frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNKNOWN, + Message: err.Error(), + }).StatusErr() +} + +// inBandError converts an arbitrary error returned by Provider.HandleEvent +// into the Error attach.go reports in-band via ErrorEvent: err's +// own Error when it carries one, or a generic +// FRONTEND_ERROR_CATEGORY_UNKNOWN otherwise. +func inBandError(err error) *Error { + var fe *Error + if errors.As(err, &fe) { + return fe + } + return &Error{ + Category: frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNKNOWN, + Message: err.Error(), + } +} + +// FatalErr signals, when returned by Fatal, that attach.go's dispatch loop +// should treat Err as a genuinely fatal condition — the plugin process +// itself failing — so the Attach stream MUST close with a gRPC status +// rather than take the ordinary in-band ServerEvent.error path every other +// Provider.HandleEvent error takes (doc.go's "Error handling is two +// distinct paths, not one"; frontend-protocol.md's error-taxonomy +// asymmetry). +type FatalErr struct { + Err error +} + +// Fatal wraps err so attach.go's dispatch loop closes the Attach stream +// with a gRPC status instead of reporting err in-band. Deliberately named +// and shaped differently from an ordinary returned error, so closing the +// long-lived stream requires an author's own deliberate choice rather than +// happening by accident. Fatal(nil) returns nil. +func Fatal(err error) error { + if err == nil { + return nil + } + return &FatalErr{Err: err} +} + +// Error implements the error interface. +func (f *FatalErr) Error() string { + return "frontend: fatal: " + f.Err.Error() +} + +// Unwrap supports errors.Is/errors.As against the wrapped error. +func (f *FatalErr) Unwrap() error { + return f.Err +} diff --git a/pkg/frontend/errors_test.go b/pkg/frontend/errors_test.go new file mode 100644 index 0000000..876b96d --- /dev/null +++ b/pkg/frontend/errors_test.go @@ -0,0 +1,89 @@ +package frontend_test + +import ( + "errors" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/pluggableharness/agent/pkg/frontend" + frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" +) + +func TestFrontendError_Error(t *testing.T) { + t.Parallel() + + fe := &frontend.Error{ + Category: frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND, + Message: "boom", + } + got := fe.Error() + want := "frontend: FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND: boom" + if got != want { + t.Errorf("Error() = %q, want %q", got, want) + } +} + +func TestFrontendError_StatusErr(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + category frontendv1.FrontendErrorCategory + wantCode codes.Code + }{ + {"unspecified", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNSPECIFIED, codes.Internal}, + {"render_failed", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_RENDER_FAILED, codes.Internal}, + {"invalid_client_event", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT, codes.InvalidArgument}, + {"region_unsupported", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED, codes.FailedPrecondition}, + {"unknown", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNKNOWN, codes.Internal}, + {"session_not_found", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND, codes.NotFound}, + {"session_create_failed", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED, codes.InvalidArgument}, + {"session_busy", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_BUSY, codes.FailedPrecondition}, + {"schema_too_new", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW, codes.FailedPrecondition}, + {"session_replay_only", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY, codes.FailedPrecondition}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fe := &frontend.Error{Category: tt.category, Message: "detail"} + err := fe.StatusErr() + + st, ok := status.FromError(err) + if !ok { + t.Fatalf("StatusErr() did not produce a *status.Status-backed error: %v", err) + } + if st.Code() != tt.wantCode { + t.Errorf("StatusErr() code = %v, want %v", st.Code(), tt.wantCode) + } + if len(st.Details()) == 0 { + t.Errorf("StatusErr() carries no structured detail") + } + }) + } +} + +func TestFatal(t *testing.T) { + t.Parallel() + + if got := frontend.Fatal(nil); got != nil { + t.Errorf("Fatal(nil) = %v, want nil", got) + } + + inner := errors.New("process died") + wrapped := frontend.Fatal(inner) + + var fatal *frontend.FatalErr + if !errors.As(wrapped, &fatal) { + t.Fatalf("Fatal(err) does not unwrap to *FatalErr: %v", wrapped) + } + if !errors.Is(wrapped, inner) { + t.Errorf("Fatal(err) does not wrap the original error via errors.Is") + } + if wrapped.Error() == "" { + t.Errorf("FatalErr.Error() returned empty string") + } +} diff --git a/pkg/frontend/fallback.go b/pkg/frontend/fallback.go new file mode 100644 index 0000000..be2d598 --- /dev/null +++ b/pkg/frontend/fallback.go @@ -0,0 +1,111 @@ +package frontend + +import ( + "fmt" + "strings" + + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// FallbackText renders any RenderNode as plain text, gracefully — the +// generic-treatment helper render-tree.md's node-type table and +// conformance.md's "RenderTree node types render gracefully, including +// unknown/unspecialized ones" MUST rule ask every frontend to have on +// hand, so an author's own Paint switch has a safe default case to call +// instead of erroring or dropping content. It handles every node type this +// package's own build knows about, and — critically — a variant added to +// the RenderNode oneof after this package shipped: node.GetNode() returns +// nil for any oneof value this generated code doesn't recognize, and the +// switch below's default case treats that identically to an explicitly +// empty node, returning "" rather than panicking on a type assertion. A +// nil node also returns "". +func FallbackText(node *renderv1.RenderNode) string { + if node == nil { + return "" + } + + switch n := node.GetNode().(type) { + case *renderv1.RenderNode_Text: + return n.Text.GetContent() + case *renderv1.RenderNode_CodeBlock: + return n.CodeBlock.GetContent() + case *renderv1.RenderNode_Diff: + return fallbackDiffText(n.Diff) + case *renderv1.RenderNode_Table: + return fallbackTableText(n.Table) + case *renderv1.RenderNode_Link: + return fmt.Sprintf("%s (%s)", n.Link.GetText(), n.Link.GetUrl()) + case *renderv1.RenderNode_List: + return fallbackChildrenText(n.List.GetItems()) + case *renderv1.RenderNode_Group: + return fallbackChildrenText(n.Group.GetChildren()) + case *renderv1.RenderNode_Collapsible: + summary := n.Collapsible.GetSummary() + body := fallbackChildrenText(n.Collapsible.GetChildren()) + if body == "" { + return summary + } + return summary + "\n" + body + case *renderv1.RenderNode_SubSession: + return n.SubSession.GetSummary() + case *renderv1.RenderNode_Action: + return n.Action.GetLabel() + default: + // A future RenderNode variant this build predates, or an entirely + // empty node — render-tree.md's graceful-fallback MUST: never + // error, never drop the caller's Paint loop, never panic on the + // unrecognized shape. + return "" + } +} + +// fallbackDiffText renders a DiffNode with no dedicated diff view as plain +// before/after text — render-tree.md's node-type table names this +// treatment explicitly for DiffNode. +func fallbackDiffText(diff *renderv1.DiffNode) string { + var b strings.Builder + for _, hunk := range diff.GetHunks() { + for i, line := range hunk.GetLines() { + if i > 0 { + b.WriteByte('\n') + } + b.WriteString(diffLinePrefix(line.GetOp())) + b.WriteString(line.GetText()) + } + } + return b.String() +} + +func diffLinePrefix(op renderv1.DiffLineOp) string { + switch op { + case renderv1.DiffLineOp_DIFF_LINE_OP_ADD: + return "+ " + case renderv1.DiffLineOp_DIFF_LINE_OP_REMOVE: + return "- " + default: + return " " + } +} + +// fallbackTableText renders a TableNode as a plain, delimited grid. +func fallbackTableText(table *renderv1.TableNode) string { + var b strings.Builder + b.WriteString(strings.Join(table.GetHeaders(), " | ")) + for _, row := range table.GetRows() { + b.WriteByte('\n') + b.WriteString(strings.Join(row.GetCells(), " | ")) + } + return b.String() +} + +// fallbackChildrenText joins the fallback text of each child node with +// newlines, skipping any that render to nothing. +func fallbackChildrenText(children []*renderv1.RenderNode) string { + var lines []string + for _, child := range children { + if text := FallbackText(child); text != "" { + lines = append(lines, text) + } + } + return strings.Join(lines, "\n") +} diff --git a/pkg/frontend/fallback_test.go b/pkg/frontend/fallback_test.go new file mode 100644 index 0000000..00c929c --- /dev/null +++ b/pkg/frontend/fallback_test.go @@ -0,0 +1,134 @@ +package frontend_test + +import ( + "strings" + "testing" + + "google.golang.org/protobuf/types/known/structpb" + + "github.com/pluggableharness/agent/pkg/frontend" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +func TestFallbackText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + node *renderv1.RenderNode + want string + }{ + {"nil node", nil, ""}, + {"empty node", &renderv1.RenderNode{}, ""}, + { + "text", + &renderv1.RenderNode{Node: &renderv1.RenderNode_Text{Text: &renderv1.TextNode{Content: "hello"}}}, + "hello", + }, + { + "code_block", + &renderv1.RenderNode{Node: &renderv1.RenderNode_CodeBlock{CodeBlock: &renderv1.CodeBlockNode{Content: "x := 1"}}}, + "x := 1", + }, + { + "link", + &renderv1.RenderNode{Node: &renderv1.RenderNode_Link{Link: &renderv1.LinkNode{Text: "docs", Url: "https://example.com"}}}, + "docs (https://example.com)", + }, + { + "sub_session", + &renderv1.RenderNode{Node: &renderv1.RenderNode_SubSession{SubSession: &renderv1.SubSessionNode{SessionId: "s1", Summary: "child task"}}}, + "child task", + }, + { + "action", + &renderv1.RenderNode{Node: &renderv1.RenderNode_Action{Action: &renderv1.ActionNode{Label: "Undo", ToolName: "undo"}}}, + "Undo", + }, + { + "table", + &renderv1.RenderNode{Node: &renderv1.RenderNode_Table{Table: &renderv1.TableNode{ + Headers: []string{"a", "b"}, + Rows: []*renderv1.TableRow{{Cells: []string{"1", "2"}}}, + }}}, + "a | b\n1 | 2", + }, + { + "list", + &renderv1.RenderNode{Node: &renderv1.RenderNode_List{List: &renderv1.ListNode{ + Items: []*renderv1.RenderNode{ + {Node: &renderv1.RenderNode_Text{Text: &renderv1.TextNode{Content: "one"}}}, + {Node: &renderv1.RenderNode_Text{Text: &renderv1.TextNode{Content: "two"}}}, + }, + }}}, + "one\ntwo", + }, + { + "group", + &renderv1.RenderNode{Node: &renderv1.RenderNode_Group{Group: &renderv1.GroupNode{ + Children: []*renderv1.RenderNode{ + {Node: &renderv1.RenderNode_Text{Text: &renderv1.TextNode{Content: "child"}}}, + }, + }}}, + "child", + }, + { + "collapsible with children", + &renderv1.RenderNode{Node: &renderv1.RenderNode_Collapsible{Collapsible: &renderv1.CollapsibleNode{ + Summary: "details", + Children: []*renderv1.RenderNode{ + {Node: &renderv1.RenderNode_Text{Text: &renderv1.TextNode{Content: "body"}}}, + }, + }}}, + "details\nbody", + }, + { + "collapsible with no children", + &renderv1.RenderNode{Node: &renderv1.RenderNode_Collapsible{Collapsible: &renderv1.CollapsibleNode{Summary: "empty"}}}, + "empty", + }, + { + "action with nil args", + &renderv1.RenderNode{Node: &renderv1.RenderNode_Action{Action: &renderv1.ActionNode{ + Label: "Run", ToolName: "run", Args: &structpb.Struct{}, + }}}, + "Run", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := frontend.FallbackText(tt.node); got != tt.want { + t.Errorf("FallbackText() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFallbackText_Diff(t *testing.T) { + t.Parallel() + + node := &renderv1.RenderNode{Node: &renderv1.RenderNode_Diff{Diff: &renderv1.DiffNode{ + Hunks: []*renderv1.DiffHunk{ + { + OldStart: 1, OldLines: 1, NewStart: 1, NewLines: 2, + Lines: []*renderv1.DiffLine{ + {Op: renderv1.DiffLineOp_DIFF_LINE_OP_CONTEXT, Text: "unchanged"}, + {Op: renderv1.DiffLineOp_DIFF_LINE_OP_REMOVE, Text: "old"}, + {Op: renderv1.DiffLineOp_DIFF_LINE_OP_ADD, Text: "new"}, + }, + }, + }, + }}} + + got := frontend.FallbackText(node) + want := " unchanged\n- old\n+ new" + if got != want { + t.Errorf("FallbackText(diff) = %q, want %q", got, want) + } + if !strings.Contains(got, "old") || !strings.Contains(got, "new") { + t.Errorf("FallbackText(diff) = %q, missing before/after text", got) + } +} diff --git a/pkg/frontend/frontend.go b/pkg/frontend/frontend.go new file mode 100644 index 0000000..a406522 --- /dev/null +++ b/pkg/frontend/frontend.go @@ -0,0 +1,456 @@ +package frontend + +import ( + "context" + + "google.golang.org/protobuf/types/known/structpb" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" + modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" + planv1 "github.com/pluggableharness/agent/pkg/plan/proto/v1" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" + sessionv1 "github.com/pluggableharness/agent/pkg/session/proto/v1" + slashcommandv1 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" +) + +// Capabilities is this frontend's static self-description, returned by +// GetCapabilities (frontend-protocol.md's "Transport" section). It MUST be +// cheaply re-derivable and MUST NOT require a network call — Provider. +// Capabilities should build it from data already resident in the plugin +// process, never fetch it remotely. +type Capabilities struct { + // SlashCommands are the prompt-expansion commands this frontend + // itself contributes. A direct-invoke command is declared exclusively + // by a slashcommand.v1 provider instead, never here. + SlashCommands []*commonv1.PromptExpansionSpec + // ConfigSchema is this provider's agent.hcl configuration schema. See + // NewCapabilities and github.com/pluggableharness/agent/pkg/config's + // Schema/Attribute builders. + ConfigSchema *configv1.ConfigSchema + // SupportedRegions are the Regions this frontend proactively declares + // it can render into — a complement to, not a replacement for, the + // reactive FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED error a + // placement it can't honor still produces. + SupportedRegions []renderv1.Region + // SupportedHookPoints are the hook points this frontend can subscribe + // to, so a mis-declared agent.hcl hook{} block naming an unsupported + // point is rejected at config-load time. + SupportedHookPoints []commonv1.HookPoint +} + +// Provider is the interface a frontend plugin author implements. NewService +// adapts a Provider into the generated frontendv1.FrontendServiceServer. +type Provider interface { + // Capabilities returns this frontend's static self-description. MUST + // be cheaply re-queryable and MUST NOT require a network call. + Capabilities(ctx context.Context) (*Capabilities, error) + // Configure applies this provider's agent.hcl configuration, already + // validated by the kernel against the ConfigSchema Capabilities + // returned. A returned *Error becomes the structured detail + // of the resulting gRPC status (see Error.StatusErr); any + // other error is wrapped as FRONTEND_ERROR_CATEGORY_UNKNOWN. + Configure(ctx context.Context, config *structpb.Struct) error + // HandleEvent is invoked once per ClientEvent this connection's + // Attach adapter receives, in arrival order, on the connection's + // single dispatch goroutine (doc.go's "Attach is one stream per + // connection, not one per session"). Implementations reply through + // emit, which is valid for the remainder of the connection's + // lifetime — including from another goroutine, after this call + // returns, e.g. to push an unsolicited render triggered by activity + // elsewhere in the plugin process — not just while HandleEvent is + // executing. + // + // A returned error surfaces in-band as ServerEvent.error and keeps + // the stream open — the path this method should reach for by default. + // Wrap it with Fatal only for a genuinely fatal condition that must + // close the stream with a gRPC status (doc.go's "Error handling is + // two distinct paths, not one"). + HandleEvent(ctx context.Context, event ClientEvent, emit Emitter) error +} + +// Emitter sends one ServerEvent at a time to the kernel over the Attach +// connection that produced the ClientEvent currently (or most recently) +// being handled. Safe for concurrent use. +type Emitter interface { + // Emit sends event. Returns a non-nil error only when the underlying + // stream itself has failed — never for an application-level condition, + // which a Provider instead reports by returning an error from + // HandleEvent (or by constructing its own ErrorEvent payload and + // calling Emit directly, for finer control over SessionID/RequestID). + Emit(event ServerEvent) error +} + +// ClientEvent is the domain form of frontendv1.ClientEvent — one event this +// connection's Attach adapter received. SessionID is REQUIRED (non-empty) +// for every session-scoped Payload variant (UserMessage, SlashCommand, +// PlanDecision, InteractiveResponse, ActionTrigger, Interrupt) and empty +// for the six connection-level control variants (Hello, CreateSession, +// AttachSession, ResumeSession, DetachSession, ListSessions), which either +// have no session yet or operate across sessions. +type ClientEvent struct { + SessionID string + Payload ClientEventPayload +} + +// ClientEventPayload is the oneof of every ClientEvent variant. Exactly +// one concrete type — UserMessage, SlashCommand, PlanDecision, +// InteractiveResponse, ActionTrigger, Interrupt, Hello, CreateSession, +// AttachSession, ResumeSession, DetachSession, or ListSessions — is ever +// assigned to ClientEvent.Payload. +type ClientEventPayload interface { + isClientEventPayload() +} + +// UserMessage is ordinary chat input. Content MUST contain at least one +// block; see github.com/pluggableharness/agent/pkg/content's Text, Image, +// Document, and other builders — never a bare string (doc.go's +// "ContentBlocks, not a bare string"). +type UserMessage struct { + Content []*contentv1.ContentBlock +} + +func (UserMessage) isClientEventPayload() {} + +// SlashCommand is a dispatched slash command invocation, resolved by the +// frontend against the kernel-supplied SlashCommandRegistry (see +// SlashCommandRegistry below) before being sent — resolution itself is +// author-side logic this package does not implement. +type SlashCommand struct { + // Name is the command name, without its leading slash. + Name string + // Args is the raw argument string following the command name. + Args string +} + +func (SlashCommand) isClientEventPayload() {} + +// PlanScope is the domain form of frontendv1.PlanDecisionScope, reordered +// so its Go zero value is PlanScopeOnce — the default a frontend SHOULD +// send absent explicit operator intent (frontend-protocol.md's +// "plan_decision.corrected_input" section) — rather than the generated +// enum's own zero value, PLAN_DECISION_SCOPE_UNSPECIFIED, which is never a +// valid decision. +type PlanScope int32 + +const ( + // PlanScopeOnce applies the decision to the named plan item only. The + // zero value, and the spec-mandated default. + PlanScopeOnce PlanScope = iota + // PlanScopeSession applies the decision to the rest of the current + // session for matching calls. + PlanScopeSession + // PlanScopeAlways asks the kernel to persist the decision as policy, + // outliving the session. The kernel MUST reject this distinctly if it + // cannot persist policy, never silently downgrading it. + PlanScopeAlways +) + +// String returns a human-readable name for s, for logging. +func (s PlanScope) String() string { + switch s { + case PlanScopeOnce: + return "once" + case PlanScopeSession: + return "session" + case PlanScopeAlways: + return "always" + default: + return "unknown" + } +} + +// PlanDecision resolves a pending PermissionRequest (see PermissionRequest +// below). CorrectedInput, when present, is an opencode-style corrected- +// argument redirect the kernel MUST re-validate against the tool's +// input_schema before treating the item as allowed. +type PlanDecision struct { + PlanItemID string + Decision frontendv1.ClientDecision + // CorrectedInput, when non-nil, replaces the plan item's tool input + // rather than a plain allow/deny. + CorrectedInput *structpb.Struct + // Scope says how durably this decision applies beyond the named item. + // The zero value is PlanScopeOnce. + Scope PlanScope +} + +func (PlanDecision) isClientEventPayload() {} + +// InteractiveResponse resolves a pending InteractiveRequest (see +// InteractiveRequest below), correlated by CallID. +type InteractiveResponse struct { + CallID string + Response *structpb.Struct +} + +func (InteractiveResponse) isClientEventPayload() {} + +// ActionTrigger is what a frontend dispatches when the operator activates +// a RenderTree's ActionNode. NodeID, ToolName, Args, and Provider MUST be +// echoed unchanged from the originating ActionNode — never rewritten +// (render-tree.md#interactive-content-the-action-node) — this is +// author-side UI discipline this package documents but cannot enforce. +type ActionTrigger struct { + NodeID string + ToolName string + Args *structpb.Struct + // Provider is the declared name of the tool provider plugin ToolName + // belongs to — ToolName is only unique per provider. + Provider string +} + +func (ActionTrigger) isClientEventPayload() {} + +// Interrupt signals that the operator wants to interrupt the current turn. +// It carries no fields. +type Interrupt struct{} + +func (Interrupt) isClientEventPayload() {} + +// Hello MAY be sent first on a newly opened Attach connection, asserting +// only the protocol version — it does not bind any session. +type Hello struct { + ProtocolVersion uint32 +} + +func (Hello) isClientEventPayload() {} + +// CreateSession creates a new session under Profile (or the kernel's +// default profile, when nil) and auto-attaches this connection to it. +// Answered by SessionCreated, correlated by RequestID. +type CreateSession struct { + RequestID string + // Profile is the agent.hcl profile to create the session under. Nil + // means the kernel's configured default profile. + Profile *string + // InitialPrompt, when non-nil, seeds the session's first turn. + InitialPrompt *string + // WorkingDirectory, when non-nil, overrides the kernel's own working + // directory at creation time. + WorkingDirectory *string +} + +func (CreateSession) isClientEventPayload() {} + +// AttachSession subscribes an existing (live or terminal) session onto +// this connection, triggering a backfill replay. Answered by +// SessionAttached, bracketing a batch closed by BackfillComplete. +type AttachSession struct { + RequestID string + SessionID string +} + +func (AttachSession) isClientEventPayload() {} + +// ResumeSession attaches a historical session for continuation or replay — +// see frontend-protocol.md's "Resume and re-open semantics". Answered +// identically to AttachSession. +type ResumeSession struct { + RequestID string + SessionID string +} + +func (ResumeSession) isClientEventPayload() {} + +// DetachSession unsubscribes a session from this connection without +// affecting the session itself or any other connection attached to it. +// Answered by SessionDetached. +type DetachSession struct { + RequestID string + SessionID string +} + +func (DetachSession) isClientEventPayload() {} + +// ListSessions requests the connection-scoped session summary list. +// Answered by SessionList. There is no DeleteSession — see +// frontend-protocol.md's "No session deletion". +type ListSessions struct { + RequestID string + // Status, when non-nil, restricts the result to sessions in this + // status. + Status *sessionv1.SessionStatus + // ParentSessionID, when non-nil, restricts the result to children of + // this session. + ParentSessionID *string + // RootsOnly restricts the result to sessions with no + // parent_session_id, ignored when false. + RootsOnly bool +} + +func (ListSessions) isClientEventPayload() {} + +// ServerEvent is the domain form of frontendv1.ServerEvent — one event +// this connection's Attach adapter sends. SessionID is set for every +// session-scoped Payload variant; empty only for the one connection-level +// variant, SessionList. RequestID, when non-nil, correlates this event +// back to the ClientEvent control message that triggered it (set on +// SessionCreated, SessionAttached, BackfillComplete, SessionDetached, +// SessionList, and on ErrorEvent when it answers a specific control +// request; nil for an ordinary live session event not triggered by a +// specific request). +type ServerEvent struct { + SessionID string + RequestID *string + Payload ServerEventPayload +} + +// ServerEventPayload is the oneof of every ServerEvent variant. +type ServerEventPayload interface { + isServerEventPayload() +} + +// StreamDelta is the fast path for incremental text display, skipping a +// full Render round trip — live-only, never used for replayed/backfilled +// text (doc.go's "Fast path vs. full render"). TargetID correlates +// consecutive deltas into one growing piece of text. +type StreamDelta struct { + TargetID string + Text string +} + +func (StreamDelta) isServerEventPayload() {} + +// Render carries one placed RenderTree to paint — a finished unit, live or +// replayed, never a partial delta (doc.go's "Fast path vs. full render"). +// A frontend MUST render every RenderNode type gracefully, including a +// variant added after this package shipped — see FallbackText. +type Render struct { + Content *renderv1.PlacedContent +} + +func (Render) isServerEventPayload() {} + +// PermissionRequest asks the operator to resolve a pending plan-apply-gate +// "ask" decision: the kernel blocks that plan item's apply until a +// matching PlanDecision resolves it. +type PermissionRequest struct { + PlanItem *planv1.PlanItem +} + +func (PermissionRequest) isServerEventPayload() {} + +// PlanReady announces a complete plan for display, e.g. before execution +// begins or after a replan. +type PlanReady struct { + Plan *planv1.Plan +} + +func (PlanReady) isServerEventPayload() {} + +// InteractiveRequest carries a kind:interactive tool call's own prompt +// content across the frontend boundary, correlated by CallID with the +// eventual InteractiveResponse. A conforming frontend MUST render Prompt +// in the REGION_OVERLAY region, the same visual treatment as an ordinary +// "ask" prompt (render-tree.md#placement--regions) — author-side UI +// discipline this package documents but cannot enforce. +type InteractiveRequest struct { + CallID string + ToolName string + Prompt *renderv1.RenderTree +} + +func (InteractiveRequest) isServerEventPayload() {} + +// SessionTreeUpdate reports a CHILD session's status change (e.g. a +// RunSession-spawned sub-agent), so a frontend can keep a SubSessionNode's +// displayed status current. For the attached session's OWN status, see +// SessionStatusUpdate below, a deliberately distinct variant. +type SessionTreeUpdate struct { + ParentSessionID string + ChildSessionID string + Status sessionv1.SessionStatus +} + +func (SessionTreeUpdate) isServerEventPayload() {} + +// ErrorEvent carries a structured, non-fatal Error for display — +// the in-band error path (doc.go's "Error handling is two distinct paths, +// not one"). +type ErrorEvent struct { + Err *Error +} + +func (ErrorEvent) isServerEventPayload() {} + +// SessionCreated acknowledges a successful CreateSession, carrying the new +// session's info. +type SessionCreated struct { + Info *sessionv1.SessionInfo +} + +func (SessionCreated) isServerEventPayload() {} + +// SessionAttached acknowledges a successful AttachSession or +// ResumeSession, carrying the session's current info and opening its +// backfill batch — the replayed Render events that follow, bracketed by +// the eventual BackfillComplete. +type SessionAttached struct { + Info *sessionv1.SessionInfo +} + +func (SessionAttached) isServerEventPayload() {} + +// BackfillComplete is the done-marker closing a backfill batch opened by +// SessionAttached. Live events with sequence > LastSequence follow. Per +// frontend-protocol.md's "Backfill" section, a backfill batch is unicast +// to the attaching connection only, never broadcast to any other frontend +// subscribed to the same session. +type BackfillComplete struct { + LastSequence int64 +} + +func (BackfillComplete) isServerEventPayload() {} + +// SessionDetached acknowledges a successful DetachSession. It carries no +// fields. +type SessionDetached struct{} + +func (SessionDetached) isServerEventPayload() {} + +// SessionList answers a ListSessions request, most-recently-started first. +// This is the one connection-level ServerEvent variant — ServerEvent's own +// SessionID is empty for it. +type SessionList struct { + Sessions []*sessionv1.SessionInfo +} + +func (SessionList) isServerEventPayload() {} + +// SlashCommandRegistry is the profile-scoped aggregate of every loaded +// provider's declared slash commands, sent on session attach and again +// whenever the registry changes. DirectInvokeCommands is declared +// exclusively by slashcommand.v1 providers; PromptExpansionCommands is +// shared vocabulary any category MAY declare. A command name MUST be +// unique jointly across both lists — the kernel enforces this at +// config-load time, not this package. +type SlashCommandRegistry struct { + DirectInvokeCommands []*slashcommandv1.SlashCommandSpec + PromptExpansionCommands []*commonv1.PromptExpansionSpec +} + +func (SlashCommandRegistry) isServerEventPayload() {} + +// UsageUpdate carries one turn's token/cost accounting and the session's +// running totals, for a context-budget indicator or similar. +type UsageUpdate struct { + Turn *modelv1.Usage + CumulativeCostUSD float64 + UsedTokens int64 + EffectiveCeiling int64 +} + +func (UsageUpdate) isServerEventPayload() {} + +// SessionStatusUpdate reports the attached session's OWN lifecycle status +// transition (e.g. RUNNING -> COMPLETED, or a bound-exhausted re-open). +// Deliberately distinct from SessionTreeUpdate above, which reports a +// CHILD session's status. +type SessionStatusUpdate struct { + Status sessionv1.SessionStatus +} + +func (SessionStatusUpdate) isServerEventPayload() {} diff --git a/pkg/frontend/helpers_test.go b/pkg/frontend/helpers_test.go new file mode 100644 index 0000000..4adbd3b --- /dev/null +++ b/pkg/frontend/helpers_test.go @@ -0,0 +1,77 @@ +package frontend_test + +import ( + "context" + "net" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/pluggableharness/agent/pkg/frontend" + frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" +) + +// newTestServer starts svc on an in-memory bufconn listener and returns a +// frontendv1.FrontendServiceClient dialed against it — a real gRPC round +// trip, not a hand-rolled interface fake, mirroring +// pkg/kernel/helpers_test.go's newTestClient. +func newTestServer(t *testing.T, svc *frontend.Service) frontendv1.FrontendServiceClient { + t.Helper() + + const bufSize = 1 << 20 + lis := bufconn.Listen(bufSize) + + gs := grpc.NewServer() + svc.Register(gs) + go func() { _ = gs.Serve(lis) }() + t.Cleanup(gs.Stop) + + dialer := func(context.Context, string) (net.Conn, error) { return lis.Dial() } + conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(dialer), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("grpc.NewClient: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + return frontendv1.NewFrontendServiceClient(conn) +} + +// fakeProvider is a hand-written frontend.Provider fake (go-testing.md: +// fakes, not mocking frameworks). Each method's behavior is controlled by +// a caller-set func field; a nil field falls through to a harmless zero +// value. +type fakeProvider struct { + capabilitiesFunc func(ctx context.Context) (*frontend.Capabilities, error) + configureFunc func(ctx context.Context, config *structpb.Struct) error + handleEventFunc func(ctx context.Context, event frontend.ClientEvent, emit frontend.Emitter) error +} + +var _ frontend.Provider = (*fakeProvider)(nil) + +func (f *fakeProvider) Capabilities(ctx context.Context) (*frontend.Capabilities, error) { + if f.capabilitiesFunc != nil { + return f.capabilitiesFunc(ctx) + } + return &frontend.Capabilities{}, nil +} + +func (f *fakeProvider) Configure(ctx context.Context, config *structpb.Struct) error { + if f.configureFunc != nil { + return f.configureFunc(ctx, config) + } + return nil +} + +func (f *fakeProvider) HandleEvent(ctx context.Context, event frontend.ClientEvent, emit frontend.Emitter) error { + if f.handleEventFunc != nil { + return f.handleEventFunc(ctx, event, emit) + } + return nil +} + +// testIdentity is a fixed plugin.Identity used across server/attach tests. +var testIdentity = plugin.Identity{Name: "test-frontend", Version: "1.0.0", Source: "github.com/pluggableharness/agent/pkg/frontend"} diff --git a/pkg/frontend/proto/v1/errors.pb.go b/pkg/frontend/proto/v1/errors.pb.go new file mode 100644 index 0000000..af34b35 --- /dev/null +++ b/pkg/frontend/proto/v1/errors.pb.go @@ -0,0 +1,249 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/frontend/v1/errors.proto + +package frontendv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// FrontendErrorCategory classifies a FrontendError, per the error taxonomy +// in frontend.md §7. +type FrontendErrorCategory int32 + +const ( + // Zero value. Never valid for a real error; its presence on the wire + // means a caller forgot to set the field. + FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNSPECIFIED FrontendErrorCategory = 0 + // A RenderTree or PlacedContent could not be displayed. + FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_RENDER_FAILED FrontendErrorCategory = 1 + // A ClientEvent was malformed or referenced an unknown/already-resolved + // id (e.g. a plan_decision or interactive_response naming an item that + // was already resolved by another attached frontend, per frontend.md + // §3.3's first-response-wins arbitration). + FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT FrontendErrorCategory = 2 + // A PlacedContent named a Region this frontend cannot honor. + FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED FrontendErrorCategory = 3 + // An error that does not fit any other category. + FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNKNOWN FrontendErrorCategory = 4 + // AttachSession, ResumeSession, DetachSession, or ListSessions' + // parent_session_id filter named a session_id the kernel has no record + // of. + FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND FrontendErrorCategory = 5 + // CreateSession failed — an invalid profile, or an unusable + // working_directory. + FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED FrontendErrorCategory = 6 + // A session-mutating control event targeted a session currently + // SESSION_STATUS_RUNNING in a way that conflicts with that (reserved + // for future session-mutating control events; no current variant in + // this protocol revision triggers it, since DetachSession is always + // safe on a running session). + FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_BUSY FrontendErrorCategory = 7 + // ResumeSession named a session file with a newer PRAGMA user_version + // than this kernel understands (state-backend.md §"Schema migration"). + FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW FrontendErrorCategory = 8 + // A user_message (or any other new-turn-inducing event) targeted a + // session attached replay-only per frontend.md §"Resume and re-open + // semantics" — a bound-exhausted (error_max_*) or FAILED session + // resumed via ResumeSession. Distinct from SESSION_BUSY: the session + // isn't running, it's terminal and specifically barred from new turns. + FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY FrontendErrorCategory = 9 +) + +// Enum value maps for FrontendErrorCategory. +var ( + FrontendErrorCategory_name = map[int32]string{ + 0: "FRONTEND_ERROR_CATEGORY_UNSPECIFIED", + 1: "FRONTEND_ERROR_CATEGORY_RENDER_FAILED", + 2: "FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT", + 3: "FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED", + 4: "FRONTEND_ERROR_CATEGORY_UNKNOWN", + 5: "FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND", + 6: "FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED", + 7: "FRONTEND_ERROR_CATEGORY_SESSION_BUSY", + 8: "FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW", + 9: "FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY", + } + FrontendErrorCategory_value = map[string]int32{ + "FRONTEND_ERROR_CATEGORY_UNSPECIFIED": 0, + "FRONTEND_ERROR_CATEGORY_RENDER_FAILED": 1, + "FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT": 2, + "FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED": 3, + "FRONTEND_ERROR_CATEGORY_UNKNOWN": 4, + "FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND": 5, + "FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED": 6, + "FRONTEND_ERROR_CATEGORY_SESSION_BUSY": 7, + "FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW": 8, + "FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY": 9, + } +) + +func (x FrontendErrorCategory) Enum() *FrontendErrorCategory { + p := new(FrontendErrorCategory) + *p = x + return p +} + +func (x FrontendErrorCategory) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FrontendErrorCategory) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_frontend_v1_errors_proto_enumTypes[0].Descriptor() +} + +func (FrontendErrorCategory) Type() protoreflect.EnumType { + return &file_pluggableharness_frontend_v1_errors_proto_enumTypes[0] +} + +func (x FrontendErrorCategory) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FrontendErrorCategory.Descriptor instead. +func (FrontendErrorCategory) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_frontend_v1_errors_proto_rawDescGZIP(), []int{0} +} + +// FrontendError is the structured error type for this category, per +// frontend.md §7. Carried in ServerEvent.Error and in the structured detail +// of a gRPC status returned from Configure. +type FrontendError struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The error's category. + Category FrontendErrorCategory `protobuf:"varint,1,opt,name=category,proto3,enum=pluggableharness.frontend.v1.FrontendErrorCategory" json:"category,omitempty"` + // A human-readable message. + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FrontendError) Reset() { + *x = FrontendError{} + mi := &file_pluggableharness_frontend_v1_errors_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FrontendError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FrontendError) ProtoMessage() {} + +func (x *FrontendError) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_frontend_v1_errors_proto_msgTypes[0] + 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 FrontendError.ProtoReflect.Descriptor instead. +func (*FrontendError) Descriptor() ([]byte, []int) { + return file_pluggableharness_frontend_v1_errors_proto_rawDescGZIP(), []int{0} +} + +func (x *FrontendError) GetCategory() FrontendErrorCategory { + if x != nil { + return x.Category + } + return FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNSPECIFIED +} + +func (x *FrontendError) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +var File_pluggableharness_frontend_v1_errors_proto protoreflect.FileDescriptor + +const file_pluggableharness_frontend_v1_errors_proto_rawDesc = "" + + "\n" + + ")pluggableharness/frontend/v1/errors.proto\x12\x1cpluggableharness.frontend.v1\"z\n" + + "\rFrontendError\x12O\n" + + "\bcategory\x18\x01 \x01(\x0e23.pluggableharness.frontend.v1.FrontendErrorCategoryR\bcategory\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage*\xdb\x03\n" + + "\x15FrontendErrorCategory\x12'\n" + + "#FRONTEND_ERROR_CATEGORY_UNSPECIFIED\x10\x00\x12)\n" + + "%FRONTEND_ERROR_CATEGORY_RENDER_FAILED\x10\x01\x120\n" + + ",FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT\x10\x02\x12.\n" + + "*FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED\x10\x03\x12#\n" + + "\x1fFRONTEND_ERROR_CATEGORY_UNKNOWN\x10\x04\x12-\n" + + ")FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND\x10\x05\x121\n" + + "-FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED\x10\x06\x12(\n" + + "$FRONTEND_ERROR_CATEGORY_SESSION_BUSY\x10\a\x12*\n" + + "&FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW\x10\b\x12/\n" + + "+FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY\x10\tBDZBgithub.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1b\x06proto3" + +var ( + file_pluggableharness_frontend_v1_errors_proto_rawDescOnce sync.Once + file_pluggableharness_frontend_v1_errors_proto_rawDescData []byte +) + +func file_pluggableharness_frontend_v1_errors_proto_rawDescGZIP() []byte { + file_pluggableharness_frontend_v1_errors_proto_rawDescOnce.Do(func() { + file_pluggableharness_frontend_v1_errors_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_frontend_v1_errors_proto_rawDesc), len(file_pluggableharness_frontend_v1_errors_proto_rawDesc))) + }) + return file_pluggableharness_frontend_v1_errors_proto_rawDescData +} + +var file_pluggableharness_frontend_v1_errors_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_pluggableharness_frontend_v1_errors_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_pluggableharness_frontend_v1_errors_proto_goTypes = []any{ + (FrontendErrorCategory)(0), // 0: pluggableharness.frontend.v1.FrontendErrorCategory + (*FrontendError)(nil), // 1: pluggableharness.frontend.v1.FrontendError +} +var file_pluggableharness_frontend_v1_errors_proto_depIdxs = []int32{ + 0, // 0: pluggableharness.frontend.v1.FrontendError.category:type_name -> pluggableharness.frontend.v1.FrontendErrorCategory + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_pluggableharness_frontend_v1_errors_proto_init() } +func file_pluggableharness_frontend_v1_errors_proto_init() { + if File_pluggableharness_frontend_v1_errors_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_frontend_v1_errors_proto_rawDesc), len(file_pluggableharness_frontend_v1_errors_proto_rawDesc)), + NumEnums: 1, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_frontend_v1_errors_proto_goTypes, + DependencyIndexes: file_pluggableharness_frontend_v1_errors_proto_depIdxs, + EnumInfos: file_pluggableharness_frontend_v1_errors_proto_enumTypes, + MessageInfos: file_pluggableharness_frontend_v1_errors_proto_msgTypes, + }.Build() + File_pluggableharness_frontend_v1_errors_proto = out.File + file_pluggableharness_frontend_v1_errors_proto_goTypes = nil + file_pluggableharness_frontend_v1_errors_proto_depIdxs = nil +} diff --git a/pkg/frontend/proto/v1/frontend.pb.go b/pkg/frontend/proto/v1/events.pb.go similarity index 64% rename from pkg/frontend/proto/v1/frontend.pb.go rename to pkg/frontend/proto/v1/events.pb.go index 3b49705..dd4c8db 100644 --- a/pkg/frontend/proto/v1/frontend.pb.go +++ b/pkg/frontend/proto/v1/events.pb.go @@ -2,23 +2,18 @@ // versions: // protoc-gen-go v1.36.11 // protoc (unknown) -// source: pluggableharness/frontend/v1/frontend.proto - -// Package pluggableharness.frontend.v1 defines the frontend provider plugin protocol -// described in specifications/frontend.md §3 (Attach, ServerEvent, -// ClientEvent, ...). +// source: pluggableharness/frontend/v1/events.proto package frontendv1 import ( - v1 "github.com/pluggableharness/agent/pkg/common/proto/v1" - v12 "github.com/pluggableharness/agent/pkg/config/proto/v1" - v17 "github.com/pluggableharness/agent/pkg/content/proto/v1" - v16 "github.com/pluggableharness/agent/pkg/model/proto/v1" - v14 "github.com/pluggableharness/agent/pkg/plan/proto/v1" - v13 "github.com/pluggableharness/agent/pkg/render/proto/v1" - v15 "github.com/pluggableharness/agent/pkg/session/proto/v1" - v11 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" + v14 "github.com/pluggableharness/agent/pkg/common/proto/v1" + v16 "github.com/pluggableharness/agent/pkg/content/proto/v1" + v15 "github.com/pluggableharness/agent/pkg/model/proto/v1" + v11 "github.com/pluggableharness/agent/pkg/plan/proto/v1" + v1 "github.com/pluggableharness/agent/pkg/render/proto/v1" + v12 "github.com/pluggableharness/agent/pkg/session/proto/v1" + v13 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" structpb "google.golang.org/protobuf/types/known/structpb" @@ -74,11 +69,11 @@ func (x ClientDecision) String() string { } func (ClientDecision) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_frontend_v1_frontend_proto_enumTypes[0].Descriptor() + return file_pluggableharness_frontend_v1_events_proto_enumTypes[0].Descriptor() } func (ClientDecision) Type() protoreflect.EnumType { - return &file_pluggableharness_frontend_v1_frontend_proto_enumTypes[0] + return &file_pluggableharness_frontend_v1_events_proto_enumTypes[0] } func (x ClientDecision) Number() protoreflect.EnumNumber { @@ -87,7 +82,7 @@ func (x ClientDecision) Number() protoreflect.EnumNumber { // Deprecated: Use ClientDecision.Descriptor instead. func (ClientDecision) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0} } // PlanDecisionScope is how durably a ClientEvent.PlanDecision applies, @@ -146,11 +141,11 @@ func (x PlanDecisionScope) String() string { } func (PlanDecisionScope) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_frontend_v1_frontend_proto_enumTypes[1].Descriptor() + return file_pluggableharness_frontend_v1_events_proto_enumTypes[1].Descriptor() } func (PlanDecisionScope) Type() protoreflect.EnumType { - return &file_pluggableharness_frontend_v1_frontend_proto_enumTypes[1] + return &file_pluggableharness_frontend_v1_events_proto_enumTypes[1] } func (x PlanDecisionScope) Number() protoreflect.EnumNumber { @@ -159,442 +154,7 @@ func (x PlanDecisionScope) Number() protoreflect.EnumNumber { // Deprecated: Use PlanDecisionScope.Descriptor instead. func (PlanDecisionScope) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{1} -} - -// FrontendErrorCategory classifies a FrontendError, per the error taxonomy -// in frontend.md §7. -type FrontendErrorCategory int32 - -const ( - // Zero value. Never valid for a real error; its presence on the wire - // means a caller forgot to set the field. - FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNSPECIFIED FrontendErrorCategory = 0 - // A RenderTree or PlacedContent could not be displayed. - FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_RENDER_FAILED FrontendErrorCategory = 1 - // A ClientEvent was malformed or referenced an unknown/already-resolved - // id (e.g. a plan_decision or interactive_response naming an item that - // was already resolved by another attached frontend, per frontend.md - // §3.3's first-response-wins arbitration). - FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT FrontendErrorCategory = 2 - // A PlacedContent named a Region this frontend cannot honor. - FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED FrontendErrorCategory = 3 - // An error that does not fit any other category. - FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNKNOWN FrontendErrorCategory = 4 - // AttachSession, ResumeSession, DetachSession, or ListSessions' - // parent_session_id filter named a session_id the kernel has no record - // of. - FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND FrontendErrorCategory = 5 - // CreateSession failed — an invalid profile, or an unusable - // working_directory. - FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED FrontendErrorCategory = 6 - // A session-mutating control event targeted a session currently - // SESSION_STATUS_RUNNING in a way that conflicts with that (reserved - // for future session-mutating control events; no current variant in - // this protocol revision triggers it, since DetachSession is always - // safe on a running session). - FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_BUSY FrontendErrorCategory = 7 - // ResumeSession named a session file with a newer PRAGMA user_version - // than this kernel understands (state-backend.md §"Schema migration"). - FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW FrontendErrorCategory = 8 - // A user_message (or any other new-turn-inducing event) targeted a - // session attached replay-only per frontend.md §"Resume and re-open - // semantics" — a bound-exhausted (error_max_*) or FAILED session - // resumed via ResumeSession. Distinct from SESSION_BUSY: the session - // isn't running, it's terminal and specifically barred from new turns. - FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY FrontendErrorCategory = 9 -) - -// Enum value maps for FrontendErrorCategory. -var ( - FrontendErrorCategory_name = map[int32]string{ - 0: "FRONTEND_ERROR_CATEGORY_UNSPECIFIED", - 1: "FRONTEND_ERROR_CATEGORY_RENDER_FAILED", - 2: "FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT", - 3: "FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED", - 4: "FRONTEND_ERROR_CATEGORY_UNKNOWN", - 5: "FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND", - 6: "FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED", - 7: "FRONTEND_ERROR_CATEGORY_SESSION_BUSY", - 8: "FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW", - 9: "FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY", - } - FrontendErrorCategory_value = map[string]int32{ - "FRONTEND_ERROR_CATEGORY_UNSPECIFIED": 0, - "FRONTEND_ERROR_CATEGORY_RENDER_FAILED": 1, - "FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT": 2, - "FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED": 3, - "FRONTEND_ERROR_CATEGORY_UNKNOWN": 4, - "FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND": 5, - "FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED": 6, - "FRONTEND_ERROR_CATEGORY_SESSION_BUSY": 7, - "FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW": 8, - "FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY": 9, - } -) - -func (x FrontendErrorCategory) Enum() *FrontendErrorCategory { - p := new(FrontendErrorCategory) - *p = x - return p -} - -func (x FrontendErrorCategory) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (FrontendErrorCategory) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_frontend_v1_frontend_proto_enumTypes[2].Descriptor() -} - -func (FrontendErrorCategory) Type() protoreflect.EnumType { - return &file_pluggableharness_frontend_v1_frontend_proto_enumTypes[2] -} - -func (x FrontendErrorCategory) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use FrontendErrorCategory.Descriptor instead. -func (FrontendErrorCategory) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{2} -} - -// DescribeRequest carries no fields — Describe takes no parameters. -type DescribeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DescribeRequest) Reset() { - *x = DescribeRequest{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DescribeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DescribeRequest) ProtoMessage() {} - -func (x *DescribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[0] - 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 DescribeRequest.ProtoReflect.Descriptor instead. -func (*DescribeRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{0} -} - -// DescribeResponse reports this plugin build's own identity, obtained -// directly from the running process rather than a lock-file row — -// configuration/lock-file.md's "dev_overrides and identity without a lock -// entry". -type DescribeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Producer *v1.ProducerRef `protobuf:"bytes,1,opt,name=producer,proto3" json:"producer,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DescribeResponse) Reset() { - *x = DescribeResponse{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DescribeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DescribeResponse) ProtoMessage() {} - -func (x *DescribeResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[1] - 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 DescribeResponse.ProtoReflect.Descriptor instead. -func (*DescribeResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{1} -} - -func (x *DescribeResponse) GetProducer() *v1.ProducerRef { - if x != nil { - return x.Producer - } - return nil -} - -// GetCapabilitiesRequest carries no fields; capability discovery is not -// parameterized. -type GetCapabilitiesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetCapabilitiesRequest) Reset() { - *x = GetCapabilitiesRequest{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetCapabilitiesRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetCapabilitiesRequest) ProtoMessage() {} - -func (x *GetCapabilitiesRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[2] - 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 GetCapabilitiesRequest.ProtoReflect.Descriptor instead. -func (*GetCapabilitiesRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{2} -} - -// GetCapabilitiesResponse wraps FrontendCapabilities for the RPC signature, -// per this repo's per-RPC envelope convention (.claude/rules/proto.md). -type GetCapabilitiesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Capabilities *FrontendCapabilities `protobuf:"bytes,1,opt,name=capabilities,proto3" json:"capabilities,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetCapabilitiesResponse) Reset() { - *x = GetCapabilitiesResponse{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetCapabilitiesResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetCapabilitiesResponse) ProtoMessage() {} - -func (x *GetCapabilitiesResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[3] - 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 GetCapabilitiesResponse.ProtoReflect.Descriptor instead. -func (*GetCapabilitiesResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{3} -} - -func (x *GetCapabilitiesResponse) GetCapabilities() *FrontendCapabilities { - if x != nil { - return x.Capabilities - } - return nil -} - -// FrontendCapabilities is this frontend's static self-description, returned -// by GetCapabilities (frontend.md §3.1). -type FrontendCapabilities struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Slash commands this frontend contributes. MAY be empty. - SlashCommands []*v11.SlashCommandSpec `protobuf:"bytes,1,rep,name=slash_commands,json=slashCommands,proto3" json:"slash_commands,omitempty"` - // This provider's `agent.hcl` configuration schema (configuration.md §4). - ConfigSchema *v12.ConfigSchema `protobuf:"bytes,2,opt,name=config_schema,json=configSchema,proto3" json:"config_schema,omitempty"` - // Regions this frontend proactively declares it can render into. A - // complement to, not a replacement for, the reactive - // FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED error a placement the - // frontend can't honor still produces — this lets a producer route - // content preferentially without waiting to find out the hard way. - SupportedRegions []v13.Region `protobuf:"varint,3,rep,packed,name=supported_regions,json=supportedRegions,proto3,enum=pluggableharness.render.v1.Region" json:"supported_regions,omitempty"` - // Hook points this frontend can subscribe to (agent-loop/hook-dispatch.md), - // so a mis-declared agent.hcl hook{} block naming an unsupported point - // can be rejected at config-load time rather than failing at first - // dispatch. - SupportedHookPoints []v1.HookPoint `protobuf:"varint,4,rep,packed,name=supported_hook_points,json=supportedHookPoints,proto3,enum=pluggableharness.common.v1.HookPoint" json:"supported_hook_points,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *FrontendCapabilities) Reset() { - *x = FrontendCapabilities{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *FrontendCapabilities) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*FrontendCapabilities) ProtoMessage() {} - -func (x *FrontendCapabilities) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[4] - 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 FrontendCapabilities.ProtoReflect.Descriptor instead. -func (*FrontendCapabilities) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{4} -} - -func (x *FrontendCapabilities) GetSlashCommands() []*v11.SlashCommandSpec { - if x != nil { - return x.SlashCommands - } - return nil -} - -func (x *FrontendCapabilities) GetConfigSchema() *v12.ConfigSchema { - if x != nil { - return x.ConfigSchema - } - return nil -} - -func (x *FrontendCapabilities) GetSupportedRegions() []v13.Region { - if x != nil { - return x.SupportedRegions - } - return nil -} - -func (x *FrontendCapabilities) GetSupportedHookPoints() []v1.HookPoint { - if x != nil { - return x.SupportedHookPoints - } - return nil -} - -// ConfigureRequest carries this provider's `agent.hcl` configuration as a -// dynamic Struct, shaped per the ConfigSchema returned by GetCapabilities -// (configuration.md §4). -type ConfigureRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The configuration value, validated against this provider's ConfigSchema. - Config *structpb.Struct `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ConfigureRequest) Reset() { - *x = ConfigureRequest{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ConfigureRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConfigureRequest) ProtoMessage() {} - -func (x *ConfigureRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_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 ConfigureRequest.ProtoReflect.Descriptor instead. -func (*ConfigureRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{5} -} - -func (x *ConfigureRequest) GetConfig() *structpb.Struct { - if x != nil { - return x.Config - } - return nil -} - -// ConfigureResponse is empty on success. Configuration errors surface as a -// gRPC status carrying a FrontendError in its structured detail -// (.claude/rules/grpc.md), not as an in-band field on this message. -type ConfigureResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ConfigureResponse) Reset() { - *x = ConfigureResponse{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ConfigureResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConfigureResponse) ProtoMessage() {} - -func (x *ConfigureResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_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 ConfigureResponse.ProtoReflect.Descriptor instead. -func (*ConfigureResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{6} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1} } // ServerEvent is one message the kernel sends to an attached frontend over @@ -639,7 +199,7 @@ type ServerEvent struct { func (x *ServerEvent) Reset() { *x = ServerEvent{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[7] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -651,7 +211,7 @@ func (x *ServerEvent) String() string { func (*ServerEvent) ProtoMessage() {} func (x *ServerEvent) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[7] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -664,7 +224,7 @@ func (x *ServerEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerEvent.ProtoReflect.Descriptor instead. func (*ServerEvent) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{7} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0} } func (x *ServerEvent) GetSessionId() string { @@ -976,7 +536,7 @@ type ClientEvent struct { func (x *ClientEvent) Reset() { *x = ClientEvent{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[8] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -988,7 +548,7 @@ func (x *ClientEvent) String() string { func (*ClientEvent) ProtoMessage() {} func (x *ClientEvent) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[8] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1001,7 +561,7 @@ func (x *ClientEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientEvent.ProtoReflect.Descriptor instead. func (*ClientEvent) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{8} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1} } func (x *ClientEvent) GetSessionId() string { @@ -1216,63 +776,6 @@ func (*ClientEvent_DetachSession_) isClientEvent_Event() {} func (*ClientEvent_ListSessions_) isClientEvent_Event() {} -// FrontendError is the structured error type for this category, per -// frontend.md §7. Carried in ServerEvent.Error and in the structured detail -// of a gRPC status returned from Configure. -type FrontendError struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The error's category. - Category FrontendErrorCategory `protobuf:"varint,1,opt,name=category,proto3,enum=pluggableharness.frontend.v1.FrontendErrorCategory" json:"category,omitempty"` - // A human-readable message. - Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *FrontendError) Reset() { - *x = FrontendError{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *FrontendError) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*FrontendError) ProtoMessage() {} - -func (x *FrontendError) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[9] - 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 FrontendError.ProtoReflect.Descriptor instead. -func (*FrontendError) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{9} -} - -func (x *FrontendError) GetCategory() FrontendErrorCategory { - if x != nil { - return x.Category - } - return FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNSPECIFIED -} - -func (x *FrontendError) GetMessage() string { - if x != nil { - return x.Message - } - return "" -} - // StreamDelta is the fast path for incremental text display, skipping a // full Render() round trip. frontend.md §3.2. type ServerEvent_StreamDelta struct { @@ -1289,7 +792,7 @@ type ServerEvent_StreamDelta struct { func (x *ServerEvent_StreamDelta) Reset() { *x = ServerEvent_StreamDelta{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[10] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1301,7 +804,7 @@ func (x *ServerEvent_StreamDelta) String() string { func (*ServerEvent_StreamDelta) ProtoMessage() {} func (x *ServerEvent_StreamDelta) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[10] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1314,7 +817,7 @@ func (x *ServerEvent_StreamDelta) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerEvent_StreamDelta.ProtoReflect.Descriptor instead. func (*ServerEvent_StreamDelta) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{7, 0} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 0} } func (x *ServerEvent_StreamDelta) GetTargetId() string { @@ -1335,14 +838,14 @@ func (x *ServerEvent_StreamDelta) GetText() string { type ServerEvent_Render struct { state protoimpl.MessageState `protogen:"open.v1"` // The content, its target region, and its replace/append behavior. - Content *v13.PlacedContent `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` + Content *v1.PlacedContent `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ServerEvent_Render) Reset() { *x = ServerEvent_Render{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[11] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1354,7 +857,7 @@ func (x *ServerEvent_Render) String() string { func (*ServerEvent_Render) ProtoMessage() {} func (x *ServerEvent_Render) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[11] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1367,10 +870,10 @@ func (x *ServerEvent_Render) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerEvent_Render.ProtoReflect.Descriptor instead. func (*ServerEvent_Render) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{7, 1} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 1} } -func (x *ServerEvent_Render) GetContent() *v13.PlacedContent { +func (x *ServerEvent_Render) GetContent() *v1.PlacedContent { if x != nil { return x.Content } @@ -1383,14 +886,14 @@ func (x *ServerEvent_Render) GetContent() *v13.PlacedContent { type ServerEvent_PermissionRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The plan item awaiting a decision. - PlanItem *v14.PlanItem `protobuf:"bytes,1,opt,name=plan_item,json=planItem,proto3" json:"plan_item,omitempty"` + PlanItem *v11.PlanItem `protobuf:"bytes,1,opt,name=plan_item,json=planItem,proto3" json:"plan_item,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ServerEvent_PermissionRequest) Reset() { *x = ServerEvent_PermissionRequest{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[12] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1402,7 +905,7 @@ func (x *ServerEvent_PermissionRequest) String() string { func (*ServerEvent_PermissionRequest) ProtoMessage() {} func (x *ServerEvent_PermissionRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[12] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1415,10 +918,10 @@ func (x *ServerEvent_PermissionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerEvent_PermissionRequest.ProtoReflect.Descriptor instead. func (*ServerEvent_PermissionRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{7, 2} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 2} } -func (x *ServerEvent_PermissionRequest) GetPlanItem() *v14.PlanItem { +func (x *ServerEvent_PermissionRequest) GetPlanItem() *v11.PlanItem { if x != nil { return x.PlanItem } @@ -1430,14 +933,14 @@ func (x *ServerEvent_PermissionRequest) GetPlanItem() *v14.PlanItem { type ServerEvent_PlanReady struct { state protoimpl.MessageState `protogen:"open.v1"` // The plan to display. - Plan *v14.Plan `protobuf:"bytes,1,opt,name=plan,proto3" json:"plan,omitempty"` + Plan *v11.Plan `protobuf:"bytes,1,opt,name=plan,proto3" json:"plan,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ServerEvent_PlanReady) Reset() { *x = ServerEvent_PlanReady{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[13] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1449,7 +952,7 @@ func (x *ServerEvent_PlanReady) String() string { func (*ServerEvent_PlanReady) ProtoMessage() {} func (x *ServerEvent_PlanReady) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[13] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1462,10 +965,10 @@ func (x *ServerEvent_PlanReady) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerEvent_PlanReady.ProtoReflect.Descriptor instead. func (*ServerEvent_PlanReady) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{7, 3} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 3} } -func (x *ServerEvent_PlanReady) GetPlan() *v14.Plan { +func (x *ServerEvent_PlanReady) GetPlan() *v11.Plan { if x != nil { return x.Plan } @@ -1484,14 +987,14 @@ type ServerEvent_InteractiveRequest struct { // The tool being invoked (tool.md §2.1 ToolSchema.name). ToolName string `protobuf:"bytes,2,opt,name=tool_name,json=toolName,proto3" json:"tool_name,omitempty"` // The prompt to render. - Prompt *v13.RenderTree `protobuf:"bytes,3,opt,name=prompt,proto3" json:"prompt,omitempty"` + Prompt *v1.RenderTree `protobuf:"bytes,3,opt,name=prompt,proto3" json:"prompt,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ServerEvent_InteractiveRequest) Reset() { *x = ServerEvent_InteractiveRequest{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[14] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1503,7 +1006,7 @@ func (x *ServerEvent_InteractiveRequest) String() string { func (*ServerEvent_InteractiveRequest) ProtoMessage() {} func (x *ServerEvent_InteractiveRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[14] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1516,7 +1019,7 @@ func (x *ServerEvent_InteractiveRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerEvent_InteractiveRequest.ProtoReflect.Descriptor instead. func (*ServerEvent_InteractiveRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{7, 4} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 4} } func (x *ServerEvent_InteractiveRequest) GetCallId() string { @@ -1533,7 +1036,7 @@ func (x *ServerEvent_InteractiveRequest) GetToolName() string { return "" } -func (x *ServerEvent_InteractiveRequest) GetPrompt() *v13.RenderTree { +func (x *ServerEvent_InteractiveRequest) GetPrompt() *v1.RenderTree { if x != nil { return x.Prompt } @@ -1552,14 +1055,14 @@ type ServerEvent_SessionTreeUpdate struct { // The child session's id. ChildSessionId string `protobuf:"bytes,2,opt,name=child_session_id,json=childSessionId,proto3" json:"child_session_id,omitempty"` // The child session's current status. - Status v15.SessionStatus `protobuf:"varint,3,opt,name=status,proto3,enum=pluggableharness.session.v1.SessionStatus" json:"status,omitempty"` + Status v12.SessionStatus `protobuf:"varint,3,opt,name=status,proto3,enum=pluggableharness.session.v1.SessionStatus" json:"status,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ServerEvent_SessionTreeUpdate) Reset() { *x = ServerEvent_SessionTreeUpdate{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[15] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1571,7 +1074,7 @@ func (x *ServerEvent_SessionTreeUpdate) String() string { func (*ServerEvent_SessionTreeUpdate) ProtoMessage() {} func (x *ServerEvent_SessionTreeUpdate) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[15] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1584,7 +1087,7 @@ func (x *ServerEvent_SessionTreeUpdate) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerEvent_SessionTreeUpdate.ProtoReflect.Descriptor instead. func (*ServerEvent_SessionTreeUpdate) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{7, 5} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 5} } func (x *ServerEvent_SessionTreeUpdate) GetParentSessionId() string { @@ -1601,11 +1104,11 @@ func (x *ServerEvent_SessionTreeUpdate) GetChildSessionId() string { return "" } -func (x *ServerEvent_SessionTreeUpdate) GetStatus() v15.SessionStatus { +func (x *ServerEvent_SessionTreeUpdate) GetStatus() v12.SessionStatus { if x != nil { return x.Status } - return v15.SessionStatus(0) + return v12.SessionStatus(0) } // Error carries a structured, non-fatal frontend error for display. @@ -1619,7 +1122,7 @@ type ServerEvent_Error struct { func (x *ServerEvent_Error) Reset() { *x = ServerEvent_Error{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[16] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1631,7 +1134,7 @@ func (x *ServerEvent_Error) String() string { func (*ServerEvent_Error) ProtoMessage() {} func (x *ServerEvent_Error) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[16] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1644,7 +1147,7 @@ func (x *ServerEvent_Error) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerEvent_Error.ProtoReflect.Descriptor instead. func (*ServerEvent_Error) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{7, 6} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 6} } func (x *ServerEvent_Error) GetError() *FrontendError { @@ -1658,14 +1161,14 @@ func (x *ServerEvent_Error) GetError() *FrontendError { type ServerEvent_SessionCreated struct { state protoimpl.MessageState `protogen:"open.v1"` // The newly created session's info. - Info *v15.SessionInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"` + Info *v12.SessionInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ServerEvent_SessionCreated) Reset() { *x = ServerEvent_SessionCreated{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[17] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1677,7 +1180,7 @@ func (x *ServerEvent_SessionCreated) String() string { func (*ServerEvent_SessionCreated) ProtoMessage() {} func (x *ServerEvent_SessionCreated) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[17] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1690,10 +1193,10 @@ func (x *ServerEvent_SessionCreated) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerEvent_SessionCreated.ProtoReflect.Descriptor instead. func (*ServerEvent_SessionCreated) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{7, 7} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 7} } -func (x *ServerEvent_SessionCreated) GetInfo() *v15.SessionInfo { +func (x *ServerEvent_SessionCreated) GetInfo() *v12.SessionInfo { if x != nil { return x.Info } @@ -1705,14 +1208,14 @@ func (x *ServerEvent_SessionCreated) GetInfo() *v15.SessionInfo { type ServerEvent_SessionAttached struct { state protoimpl.MessageState `protogen:"open.v1"` // The attached session's current info. - Info *v15.SessionInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"` + Info *v12.SessionInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ServerEvent_SessionAttached) Reset() { *x = ServerEvent_SessionAttached{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[18] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1724,7 +1227,7 @@ func (x *ServerEvent_SessionAttached) String() string { func (*ServerEvent_SessionAttached) ProtoMessage() {} func (x *ServerEvent_SessionAttached) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[18] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1737,10 +1240,10 @@ func (x *ServerEvent_SessionAttached) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerEvent_SessionAttached.ProtoReflect.Descriptor instead. func (*ServerEvent_SessionAttached) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{7, 8} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 8} } -func (x *ServerEvent_SessionAttached) GetInfo() *v15.SessionInfo { +func (x *ServerEvent_SessionAttached) GetInfo() *v12.SessionInfo { if x != nil { return x.Info } @@ -1760,7 +1263,7 @@ type ServerEvent_BackfillComplete struct { func (x *ServerEvent_BackfillComplete) Reset() { *x = ServerEvent_BackfillComplete{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[19] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1772,7 +1275,7 @@ func (x *ServerEvent_BackfillComplete) String() string { func (*ServerEvent_BackfillComplete) ProtoMessage() {} func (x *ServerEvent_BackfillComplete) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[19] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1785,7 +1288,7 @@ func (x *ServerEvent_BackfillComplete) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerEvent_BackfillComplete.ProtoReflect.Descriptor instead. func (*ServerEvent_BackfillComplete) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{7, 9} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 9} } func (x *ServerEvent_BackfillComplete) GetLastSequence() int64 { @@ -1804,7 +1307,7 @@ type ServerEvent_SessionDetached struct { func (x *ServerEvent_SessionDetached) Reset() { *x = ServerEvent_SessionDetached{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[20] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1816,7 +1319,7 @@ func (x *ServerEvent_SessionDetached) String() string { func (*ServerEvent_SessionDetached) ProtoMessage() {} func (x *ServerEvent_SessionDetached) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[20] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1829,21 +1332,21 @@ func (x *ServerEvent_SessionDetached) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerEvent_SessionDetached.ProtoReflect.Descriptor instead. func (*ServerEvent_SessionDetached) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{7, 10} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 10} } // SessionList answers a ClientEvent.ListSessions. type ServerEvent_SessionList struct { state protoimpl.MessageState `protogen:"open.v1"` // The matching sessions, most-recently-started first. - Sessions []*v15.SessionInfo `protobuf:"bytes,1,rep,name=sessions,proto3" json:"sessions,omitempty"` + Sessions []*v12.SessionInfo `protobuf:"bytes,1,rep,name=sessions,proto3" json:"sessions,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ServerEvent_SessionList) Reset() { *x = ServerEvent_SessionList{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[21] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1855,7 +1358,7 @@ func (x *ServerEvent_SessionList) String() string { func (*ServerEvent_SessionList) ProtoMessage() {} func (x *ServerEvent_SessionList) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[21] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1868,10 +1371,10 @@ func (x *ServerEvent_SessionList) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerEvent_SessionList.ProtoReflect.Descriptor instead. func (*ServerEvent_SessionList) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{7, 11} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 11} } -func (x *ServerEvent_SessionList) GetSessions() []*v15.SessionInfo { +func (x *ServerEvent_SessionList) GetSessions() []*v12.SessionInfo { if x != nil { return x.Sessions } @@ -1880,19 +1383,31 @@ func (x *ServerEvent_SessionList) GetSessions() []*v15.SessionInfo { // SlashCommandRegistry is the profile-scoped aggregate of every loaded // provider's declared slash commands for this session, per -// frontend.md §"Slash commands". +// frontend.md §"Slash commands" and specifications/slashcommand/. Two +// separate lists rather than one, since the two kinds are declared by +// different provider categories and dispatched differently — a +// frontend distinguishes them the same way it renders them (a `/name` +// lookup checks both), but the kernel keeps their namespaces distinct +// per-list while still enforcing one combined collision check across +// both at config-load time. type ServerEvent_SlashCommandRegistry struct { state protoimpl.MessageState `protogen:"open.v1"` - // Every registered command, name-collision-checked at config-load - // time (frontend.md §"Slash commands"). - Commands []*v11.SlashCommandSpec `protobuf:"bytes,1,rep,name=commands,proto3" json:"commands,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Every registered direct-invoke command, declared by a + // slashcommand.v1 provider's own GetCapabilities response. + // Name-collision-checked (jointly with prompt_expansion_commands + // below) at config-load time (frontend.md §"Slash commands"). + DirectInvokeCommands []*v13.SlashCommandSpec `protobuf:"bytes,1,rep,name=direct_invoke_commands,json=directInvokeCommands,proto3" json:"direct_invoke_commands,omitempty"` + // Every registered prompt-expansion command, declared by any + // category's own capability response. Name-collision-checked + // (jointly with direct_invoke_commands above) at config-load time. + PromptExpansionCommands []*v14.PromptExpansionSpec `protobuf:"bytes,2,rep,name=prompt_expansion_commands,json=promptExpansionCommands,proto3" json:"prompt_expansion_commands,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ServerEvent_SlashCommandRegistry) Reset() { *x = ServerEvent_SlashCommandRegistry{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[22] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1904,7 +1419,7 @@ func (x *ServerEvent_SlashCommandRegistry) String() string { func (*ServerEvent_SlashCommandRegistry) ProtoMessage() {} func (x *ServerEvent_SlashCommandRegistry) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[22] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1917,12 +1432,19 @@ func (x *ServerEvent_SlashCommandRegistry) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerEvent_SlashCommandRegistry.ProtoReflect.Descriptor instead. func (*ServerEvent_SlashCommandRegistry) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{7, 12} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 12} +} + +func (x *ServerEvent_SlashCommandRegistry) GetDirectInvokeCommands() []*v13.SlashCommandSpec { + if x != nil { + return x.DirectInvokeCommands + } + return nil } -func (x *ServerEvent_SlashCommandRegistry) GetCommands() []*v11.SlashCommandSpec { +func (x *ServerEvent_SlashCommandRegistry) GetPromptExpansionCommands() []*v14.PromptExpansionSpec { if x != nil { - return x.Commands + return x.PromptExpansionCommands } return nil } @@ -1932,7 +1454,7 @@ func (x *ServerEvent_SlashCommandRegistry) GetCommands() []*v11.SlashCommandSpec type ServerEvent_UsageUpdate struct { state protoimpl.MessageState `protogen:"open.v1"` // This turn's token accounting. - Turn *v16.Usage `protobuf:"bytes,1,opt,name=turn,proto3" json:"turn,omitempty"` + Turn *v15.Usage `protobuf:"bytes,1,opt,name=turn,proto3" json:"turn,omitempty"` // The session's running total spend in USD, mirroring // session.v1.SessionInfo.cost_usd. CumulativeCostUsd float64 `protobuf:"fixed64,2,opt,name=cumulative_cost_usd,json=cumulativeCostUsd,proto3" json:"cumulative_cost_usd,omitempty"` @@ -1949,7 +1471,7 @@ type ServerEvent_UsageUpdate struct { func (x *ServerEvent_UsageUpdate) Reset() { *x = ServerEvent_UsageUpdate{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[23] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1961,7 +1483,7 @@ func (x *ServerEvent_UsageUpdate) String() string { func (*ServerEvent_UsageUpdate) ProtoMessage() {} func (x *ServerEvent_UsageUpdate) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[23] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1974,10 +1496,10 @@ func (x *ServerEvent_UsageUpdate) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerEvent_UsageUpdate.ProtoReflect.Descriptor instead. func (*ServerEvent_UsageUpdate) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{7, 13} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 13} } -func (x *ServerEvent_UsageUpdate) GetTurn() *v16.Usage { +func (x *ServerEvent_UsageUpdate) GetTurn() *v15.Usage { if x != nil { return x.Turn } @@ -2011,14 +1533,14 @@ func (x *ServerEvent_UsageUpdate) GetEffectiveCeiling() int64 { type ServerEvent_SessionStatusUpdate struct { state protoimpl.MessageState `protogen:"open.v1"` // The session's new status. - Status v15.SessionStatus `protobuf:"varint,1,opt,name=status,proto3,enum=pluggableharness.session.v1.SessionStatus" json:"status,omitempty"` + Status v12.SessionStatus `protobuf:"varint,1,opt,name=status,proto3,enum=pluggableharness.session.v1.SessionStatus" json:"status,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ServerEvent_SessionStatusUpdate) Reset() { *x = ServerEvent_SessionStatusUpdate{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[24] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2030,7 +1552,7 @@ func (x *ServerEvent_SessionStatusUpdate) String() string { func (*ServerEvent_SessionStatusUpdate) ProtoMessage() {} func (x *ServerEvent_SessionStatusUpdate) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[24] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2043,14 +1565,14 @@ func (x *ServerEvent_SessionStatusUpdate) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerEvent_SessionStatusUpdate.ProtoReflect.Descriptor instead. func (*ServerEvent_SessionStatusUpdate) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{7, 14} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 14} } -func (x *ServerEvent_SessionStatusUpdate) GetStatus() v15.SessionStatus { +func (x *ServerEvent_SessionStatusUpdate) GetStatus() v12.SessionStatus { if x != nil { return x.Status } - return v15.SessionStatus(0) + return v12.SessionStatus(0) } // UserMessage is ordinary chat input from the user. @@ -2058,14 +1580,14 @@ type ClientEvent_UserMessage struct { state protoimpl.MessageState `protogen:"open.v1"` // The message content, in emission order. MUST contain at least one // block. - Content []*v17.ContentBlock `protobuf:"bytes,2,rep,name=content,proto3" json:"content,omitempty"` + Content []*v16.ContentBlock `protobuf:"bytes,2,rep,name=content,proto3" json:"content,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ClientEvent_UserMessage) Reset() { *x = ClientEvent_UserMessage{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[25] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2077,7 +1599,7 @@ func (x *ClientEvent_UserMessage) String() string { func (*ClientEvent_UserMessage) ProtoMessage() {} func (x *ClientEvent_UserMessage) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[25] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2090,10 +1612,10 @@ func (x *ClientEvent_UserMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientEvent_UserMessage.ProtoReflect.Descriptor instead. func (*ClientEvent_UserMessage) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{8, 0} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 0} } -func (x *ClientEvent_UserMessage) GetContent() []*v17.ContentBlock { +func (x *ClientEvent_UserMessage) GetContent() []*v16.ContentBlock { if x != nil { return x.Content } @@ -2113,7 +1635,7 @@ type ClientEvent_SlashCommand struct { func (x *ClientEvent_SlashCommand) Reset() { *x = ClientEvent_SlashCommand{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[26] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2125,7 +1647,7 @@ func (x *ClientEvent_SlashCommand) String() string { func (*ClientEvent_SlashCommand) ProtoMessage() {} func (x *ClientEvent_SlashCommand) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[26] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2138,7 +1660,7 @@ func (x *ClientEvent_SlashCommand) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientEvent_SlashCommand.ProtoReflect.Descriptor instead. func (*ClientEvent_SlashCommand) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{8, 1} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 1} } func (x *ClientEvent_SlashCommand) GetName() string { @@ -2177,7 +1699,7 @@ type ClientEvent_PlanDecision struct { func (x *ClientEvent_PlanDecision) Reset() { *x = ClientEvent_PlanDecision{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[27] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2189,7 +1711,7 @@ func (x *ClientEvent_PlanDecision) String() string { func (*ClientEvent_PlanDecision) ProtoMessage() {} func (x *ClientEvent_PlanDecision) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[27] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2202,7 +1724,7 @@ func (x *ClientEvent_PlanDecision) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientEvent_PlanDecision.ProtoReflect.Descriptor instead. func (*ClientEvent_PlanDecision) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{8, 2} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 2} } func (x *ClientEvent_PlanDecision) GetPlanItemId() string { @@ -2247,7 +1769,7 @@ type ClientEvent_InteractiveResponse struct { func (x *ClientEvent_InteractiveResponse) Reset() { *x = ClientEvent_InteractiveResponse{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[28] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2259,7 +1781,7 @@ func (x *ClientEvent_InteractiveResponse) String() string { func (*ClientEvent_InteractiveResponse) ProtoMessage() {} func (x *ClientEvent_InteractiveResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[28] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2272,7 +1794,7 @@ func (x *ClientEvent_InteractiveResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientEvent_InteractiveResponse.ProtoReflect.Descriptor instead. func (*ClientEvent_InteractiveResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{8, 3} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 3} } func (x *ClientEvent_InteractiveResponse) GetCallId() string { @@ -2290,13 +1812,13 @@ func (x *ClientEvent_InteractiveResponse) GetResponse() *structpb.Struct { } // ActionTrigger is dispatched when a user activates a RenderNode's -// ActionNode (render.proto's ActionNode, frontend.md §5.1). The kernel +// ActionNode (render/v1/types.proto's ActionNode, frontend.md §5.1). The kernel // handles this identically to a direct_invoke slash command: the normal // Invoke/plan-apply pipeline including policy evaluation, with no model // turn. type ClientEvent_ActionTrigger struct { state protoimpl.MessageState `protogen:"open.v1"` - // The originating ActionNode's id (render.proto's ActionNode.id). + // The originating ActionNode's id (render/v1/types.proto's ActionNode.id). NodeId string `protobuf:"bytes,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` // The tool operation to invoke (tool.md §2 ToolSchema.name), echoed // unchanged from the originating ActionNode.tool_name. @@ -2306,7 +1828,7 @@ type ClientEvent_ActionTrigger struct { Args *structpb.Struct `protobuf:"bytes,3,opt,name=args,proto3" json:"args,omitempty"` // The declared name of the tool provider plugin `tool_name` belongs // to, echoed unchanged from the originating ActionNode.provider - // (render.proto) — tool_name is only unique per provider. + // (render/v1/types.proto) — tool_name is only unique per provider. Provider string `protobuf:"bytes,4,opt,name=provider,proto3" json:"provider,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -2314,7 +1836,7 @@ type ClientEvent_ActionTrigger struct { func (x *ClientEvent_ActionTrigger) Reset() { *x = ClientEvent_ActionTrigger{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[29] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2326,7 +1848,7 @@ func (x *ClientEvent_ActionTrigger) String() string { func (*ClientEvent_ActionTrigger) ProtoMessage() {} func (x *ClientEvent_ActionTrigger) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[29] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2339,7 +1861,7 @@ func (x *ClientEvent_ActionTrigger) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientEvent_ActionTrigger.ProtoReflect.Descriptor instead. func (*ClientEvent_ActionTrigger) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{8, 4} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 4} } func (x *ClientEvent_ActionTrigger) GetNodeId() string { @@ -2380,7 +1902,7 @@ type ClientEvent_Interrupt struct { func (x *ClientEvent_Interrupt) Reset() { *x = ClientEvent_Interrupt{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[30] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2392,7 +1914,7 @@ func (x *ClientEvent_Interrupt) String() string { func (*ClientEvent_Interrupt) ProtoMessage() {} func (x *ClientEvent_Interrupt) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[30] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2405,7 +1927,7 @@ func (x *ClientEvent_Interrupt) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientEvent_Interrupt.ProtoReflect.Descriptor instead. func (*ClientEvent_Interrupt) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{8, 5} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 5} } // Hello MAY be sent as the first ClientEvent on a newly opened Attach @@ -2421,7 +1943,7 @@ type ClientEvent_Hello struct { func (x *ClientEvent_Hello) Reset() { *x = ClientEvent_Hello{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[31] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2433,7 +1955,7 @@ func (x *ClientEvent_Hello) String() string { func (*ClientEvent_Hello) ProtoMessage() {} func (x *ClientEvent_Hello) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[31] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2446,7 +1968,7 @@ func (x *ClientEvent_Hello) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientEvent_Hello.ProtoReflect.Descriptor instead. func (*ClientEvent_Hello) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{8, 6} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 6} } func (x *ClientEvent_Hello) GetProtocolVersion() uint32 { @@ -2479,7 +2001,7 @@ type ClientEvent_CreateSession struct { func (x *ClientEvent_CreateSession) Reset() { *x = ClientEvent_CreateSession{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[32] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2491,7 +2013,7 @@ func (x *ClientEvent_CreateSession) String() string { func (*ClientEvent_CreateSession) ProtoMessage() {} func (x *ClientEvent_CreateSession) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[32] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2504,7 +2026,7 @@ func (x *ClientEvent_CreateSession) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientEvent_CreateSession.ProtoReflect.Descriptor instead. func (*ClientEvent_CreateSession) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{8, 7} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 7} } func (x *ClientEvent_CreateSession) GetRequestId() string { @@ -2551,7 +2073,7 @@ type ClientEvent_AttachSession struct { func (x *ClientEvent_AttachSession) Reset() { *x = ClientEvent_AttachSession{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[33] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2563,7 +2085,7 @@ func (x *ClientEvent_AttachSession) String() string { func (*ClientEvent_AttachSession) ProtoMessage() {} func (x *ClientEvent_AttachSession) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[33] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2576,7 +2098,7 @@ func (x *ClientEvent_AttachSession) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientEvent_AttachSession.ProtoReflect.Descriptor instead. func (*ClientEvent_AttachSession) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{8, 8} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 8} } func (x *ClientEvent_AttachSession) GetRequestId() string { @@ -2613,7 +2135,7 @@ type ClientEvent_ResumeSession struct { func (x *ClientEvent_ResumeSession) Reset() { *x = ClientEvent_ResumeSession{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[34] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2625,7 +2147,7 @@ func (x *ClientEvent_ResumeSession) String() string { func (*ClientEvent_ResumeSession) ProtoMessage() {} func (x *ClientEvent_ResumeSession) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[34] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2638,7 +2160,7 @@ func (x *ClientEvent_ResumeSession) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientEvent_ResumeSession.ProtoReflect.Descriptor instead. func (*ClientEvent_ResumeSession) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{8, 9} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 9} } func (x *ClientEvent_ResumeSession) GetRequestId() string { @@ -2671,7 +2193,7 @@ type ClientEvent_DetachSession struct { func (x *ClientEvent_DetachSession) Reset() { *x = ClientEvent_DetachSession{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[35] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2683,7 +2205,7 @@ func (x *ClientEvent_DetachSession) String() string { func (*ClientEvent_DetachSession) ProtoMessage() {} func (x *ClientEvent_DetachSession) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[35] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2696,7 +2218,7 @@ func (x *ClientEvent_DetachSession) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientEvent_DetachSession.ProtoReflect.Descriptor instead. func (*ClientEvent_DetachSession) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{8, 10} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 10} } func (x *ClientEvent_DetachSession) GetRequestId() string { @@ -2721,7 +2243,7 @@ type ClientEvent_ListSessions struct { RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` // Restricts the result to sessions in this status. Absent means no // status filter. - Status *v15.SessionStatus `protobuf:"varint,2,opt,name=status,proto3,enum=pluggableharness.session.v1.SessionStatus,oneof" json:"status,omitempty"` + Status *v12.SessionStatus `protobuf:"varint,2,opt,name=status,proto3,enum=pluggableharness.session.v1.SessionStatus,oneof" json:"status,omitempty"` // Restricts the result to children of this session. Absent means no // parent filter. ParentSessionId *string `protobuf:"bytes,3,opt,name=parent_session_id,json=parentSessionId,proto3,oneof" json:"parent_session_id,omitempty"` @@ -2734,7 +2256,7 @@ type ClientEvent_ListSessions struct { func (x *ClientEvent_ListSessions) Reset() { *x = ClientEvent_ListSessions{} - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[36] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2746,7 +2268,7 @@ func (x *ClientEvent_ListSessions) String() string { func (*ClientEvent_ListSessions) ProtoMessage() {} func (x *ClientEvent_ListSessions) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_frontend_proto_msgTypes[36] + mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2759,7 +2281,7 @@ func (x *ClientEvent_ListSessions) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientEvent_ListSessions.ProtoReflect.Descriptor instead. func (*ClientEvent_ListSessions) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP(), []int{8, 11} + return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 11} } func (x *ClientEvent_ListSessions) GetRequestId() string { @@ -2769,11 +2291,11 @@ func (x *ClientEvent_ListSessions) GetRequestId() string { return "" } -func (x *ClientEvent_ListSessions) GetStatus() v15.SessionStatus { +func (x *ClientEvent_ListSessions) GetStatus() v12.SessionStatus { if x != nil && x.Status != nil { return *x.Status } - return v15.SessionStatus(0) + return v12.SessionStatus(0) } func (x *ClientEvent_ListSessions) GetParentSessionId() string { @@ -2790,25 +2312,11 @@ func (x *ClientEvent_ListSessions) GetRootsOnly() bool { return false } -var File_pluggableharness_frontend_v1_frontend_proto protoreflect.FileDescriptor +var File_pluggableharness_frontend_v1_events_proto protoreflect.FileDescriptor -const file_pluggableharness_frontend_v1_frontend_proto_rawDesc = "" + +const file_pluggableharness_frontend_v1_events_proto_rawDesc = "" + "\n" + - "+pluggableharness/frontend/v1/frontend.proto\x12\x1cpluggableharness.frontend.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a'pluggableharness/common/v1/common.proto\x1a'pluggableharness/config/v1/config.proto\x1a)pluggableharness/content/v1/content.proto\x1a%pluggableharness/model/v1/model.proto\x1a#pluggableharness/plan/v1/plan.proto\x1a'pluggableharness/render/v1/render.proto\x1a)pluggableharness/session/v1/session.proto\x1a3pluggableharness/slashcommand/v1/slashcommand.proto\"\x11\n" + - "\x0fDescribeRequest\"W\n" + - "\x10DescribeResponse\x12C\n" + - "\bproducer\x18\x01 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\bproducer\"\x18\n" + - "\x16GetCapabilitiesRequest\"q\n" + - "\x17GetCapabilitiesResponse\x12V\n" + - "\fcapabilities\x18\x01 \x01(\v22.pluggableharness.frontend.v1.FrontendCapabilitiesR\fcapabilities\"\xec\x02\n" + - "\x14FrontendCapabilities\x12Y\n" + - "\x0eslash_commands\x18\x01 \x03(\v22.pluggableharness.slashcommand.v1.SlashCommandSpecR\rslashCommands\x12M\n" + - "\rconfig_schema\x18\x02 \x01(\v2(.pluggableharness.config.v1.ConfigSchemaR\fconfigSchema\x12O\n" + - "\x11supported_regions\x18\x03 \x03(\x0e2\".pluggableharness.render.v1.RegionR\x10supportedRegions\x12Y\n" + - "\x15supported_hook_points\x18\x04 \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPoints\"C\n" + - "\x10ConfigureRequest\x12/\n" + - "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06config\"\x13\n" + - "\x11ConfigureResponse\"\xd1\x17\n" + + ")pluggableharness/frontend/v1/events.proto\x12\x1cpluggableharness.frontend.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a&pluggableharness/common/v1/types.proto\x1a'pluggableharness/content/v1/types.proto\x1a)pluggableharness/frontend/v1/errors.proto\x1a%pluggableharness/model/v1/types.proto\x1a$pluggableharness/plan/v1/types.proto\x1a&pluggableharness/render/v1/types.proto\x1a'pluggableharness/session/v1/types.proto\x1a,pluggableharness/slashcommand/v1/types.proto\"\xd9\x18\n" + "\vServerEvent\x12\x1d\n" + "\n" + "session_id\x18d \x01(\tR\tsessionId\x12\"\n" + @@ -2858,9 +2366,10 @@ const file_pluggableharness_frontend_v1_frontend_proto_rawDesc = "" + "\rlast_sequence\x18\x01 \x01(\x03R\flastSequence\x1a\x11\n" + "\x0fSessionDetached\x1aS\n" + "\vSessionList\x12D\n" + - "\bsessions\x18\x01 \x03(\v2(.pluggableharness.session.v1.SessionInfoR\bsessions\x1af\n" + - "\x14SlashCommandRegistry\x12N\n" + - "\bcommands\x18\x01 \x03(\v22.pluggableharness.slashcommand.v1.SlashCommandSpecR\bcommands\x1a\xc1\x01\n" + + "\bsessions\x18\x01 \x03(\v2(.pluggableharness.session.v1.SessionInfoR\bsessions\x1a\xed\x01\n" + + "\x14SlashCommandRegistry\x12h\n" + + "\x16direct_invoke_commands\x18\x01 \x03(\v22.pluggableharness.slashcommand.v1.SlashCommandSpecR\x14directInvokeCommands\x12k\n" + + "\x19prompt_expansion_commands\x18\x02 \x03(\v2/.pluggableharness.common.v1.PromptExpansionSpecR\x17promptExpansionCommands\x1a\xc1\x01\n" + "\vUsageUpdate\x124\n" + "\x04turn\x18\x01 \x01(\v2 .pluggableharness.model.v1.UsageR\x04turn\x12.\n" + "\x13cumulative_cost_usd\x18\x02 \x01(\x01R\x11cumulativeCostUsd\x12\x1f\n" + @@ -2944,10 +2453,7 @@ const file_pluggableharness_frontend_v1_frontend_proto_rawDesc = "" + "roots_only\x18\x04 \x01(\bR\trootsOnlyB\t\n" + "\a_statusB\x14\n" + "\x12_parent_session_idB\a\n" + - "\x05event\"z\n" + - "\rFrontendError\x12O\n" + - "\bcategory\x18\x01 \x01(\x0e23.pluggableharness.frontend.v1.FrontendErrorCategoryR\bcategory\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage*f\n" + + "\x05event*f\n" + "\x0eClientDecision\x12\x1f\n" + "\x1bCLIENT_DECISION_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15CLIENT_DECISION_ALLOW\x10\x01\x12\x18\n" + @@ -2956,170 +2462,129 @@ const file_pluggableharness_frontend_v1_frontend_proto_rawDesc = "" + "\x1fPLAN_DECISION_SCOPE_UNSPECIFIED\x10\x00\x12\x1c\n" + "\x18PLAN_DECISION_SCOPE_ONCE\x10\x01\x12\x1f\n" + "\x1bPLAN_DECISION_SCOPE_SESSION\x10\x02\x12\x1e\n" + - "\x1aPLAN_DECISION_SCOPE_ALWAYS\x10\x03*\xdb\x03\n" + - "\x15FrontendErrorCategory\x12'\n" + - "#FRONTEND_ERROR_CATEGORY_UNSPECIFIED\x10\x00\x12)\n" + - "%FRONTEND_ERROR_CATEGORY_RENDER_FAILED\x10\x01\x120\n" + - ",FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT\x10\x02\x12.\n" + - "*FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED\x10\x03\x12#\n" + - "\x1fFRONTEND_ERROR_CATEGORY_UNKNOWN\x10\x04\x12-\n" + - ")FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND\x10\x05\x121\n" + - "-FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED\x10\x06\x12(\n" + - "$FRONTEND_ERROR_CATEGORY_SESSION_BUSY\x10\a\x12*\n" + - "&FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW\x10\b\x12/\n" + - "+FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY\x10\t2\xce\x03\n" + - "\x0fFrontendService\x12~\n" + - "\x0fGetCapabilities\x124.pluggableharness.frontend.v1.GetCapabilitiesRequest\x1a5.pluggableharness.frontend.v1.GetCapabilitiesResponse\x12l\n" + - "\tConfigure\x12..pluggableharness.frontend.v1.ConfigureRequest\x1a/.pluggableharness.frontend.v1.ConfigureResponse\x12b\n" + - "\x06Attach\x12).pluggableharness.frontend.v1.ClientEvent\x1a).pluggableharness.frontend.v1.ServerEvent(\x010\x01\x12i\n" + - "\bDescribe\x12-.pluggableharness.frontend.v1.DescribeRequest\x1a..pluggableharness.frontend.v1.DescribeResponseBDZBgithub.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1b\x06proto3" + "\x1aPLAN_DECISION_SCOPE_ALWAYS\x10\x03BDZBgithub.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1b\x06proto3" var ( - file_pluggableharness_frontend_v1_frontend_proto_rawDescOnce sync.Once - file_pluggableharness_frontend_v1_frontend_proto_rawDescData []byte + file_pluggableharness_frontend_v1_events_proto_rawDescOnce sync.Once + file_pluggableharness_frontend_v1_events_proto_rawDescData []byte ) -func file_pluggableharness_frontend_v1_frontend_proto_rawDescGZIP() []byte { - file_pluggableharness_frontend_v1_frontend_proto_rawDescOnce.Do(func() { - file_pluggableharness_frontend_v1_frontend_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_frontend_v1_frontend_proto_rawDesc), len(file_pluggableharness_frontend_v1_frontend_proto_rawDesc))) +func file_pluggableharness_frontend_v1_events_proto_rawDescGZIP() []byte { + file_pluggableharness_frontend_v1_events_proto_rawDescOnce.Do(func() { + file_pluggableharness_frontend_v1_events_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_frontend_v1_events_proto_rawDesc), len(file_pluggableharness_frontend_v1_events_proto_rawDesc))) }) - return file_pluggableharness_frontend_v1_frontend_proto_rawDescData + return file_pluggableharness_frontend_v1_events_proto_rawDescData } -var file_pluggableharness_frontend_v1_frontend_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_pluggableharness_frontend_v1_frontend_proto_msgTypes = make([]protoimpl.MessageInfo, 37) -var file_pluggableharness_frontend_v1_frontend_proto_goTypes = []any{ +var file_pluggableharness_frontend_v1_events_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_pluggableharness_frontend_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 29) +var file_pluggableharness_frontend_v1_events_proto_goTypes = []any{ (ClientDecision)(0), // 0: pluggableharness.frontend.v1.ClientDecision (PlanDecisionScope)(0), // 1: pluggableharness.frontend.v1.PlanDecisionScope - (FrontendErrorCategory)(0), // 2: pluggableharness.frontend.v1.FrontendErrorCategory - (*DescribeRequest)(nil), // 3: pluggableharness.frontend.v1.DescribeRequest - (*DescribeResponse)(nil), // 4: pluggableharness.frontend.v1.DescribeResponse - (*GetCapabilitiesRequest)(nil), // 5: pluggableharness.frontend.v1.GetCapabilitiesRequest - (*GetCapabilitiesResponse)(nil), // 6: pluggableharness.frontend.v1.GetCapabilitiesResponse - (*FrontendCapabilities)(nil), // 7: pluggableharness.frontend.v1.FrontendCapabilities - (*ConfigureRequest)(nil), // 8: pluggableharness.frontend.v1.ConfigureRequest - (*ConfigureResponse)(nil), // 9: pluggableharness.frontend.v1.ConfigureResponse - (*ServerEvent)(nil), // 10: pluggableharness.frontend.v1.ServerEvent - (*ClientEvent)(nil), // 11: pluggableharness.frontend.v1.ClientEvent - (*FrontendError)(nil), // 12: pluggableharness.frontend.v1.FrontendError - (*ServerEvent_StreamDelta)(nil), // 13: pluggableharness.frontend.v1.ServerEvent.StreamDelta - (*ServerEvent_Render)(nil), // 14: pluggableharness.frontend.v1.ServerEvent.Render - (*ServerEvent_PermissionRequest)(nil), // 15: pluggableharness.frontend.v1.ServerEvent.PermissionRequest - (*ServerEvent_PlanReady)(nil), // 16: pluggableharness.frontend.v1.ServerEvent.PlanReady - (*ServerEvent_InteractiveRequest)(nil), // 17: pluggableharness.frontend.v1.ServerEvent.InteractiveRequest - (*ServerEvent_SessionTreeUpdate)(nil), // 18: pluggableharness.frontend.v1.ServerEvent.SessionTreeUpdate - (*ServerEvent_Error)(nil), // 19: pluggableharness.frontend.v1.ServerEvent.Error - (*ServerEvent_SessionCreated)(nil), // 20: pluggableharness.frontend.v1.ServerEvent.SessionCreated - (*ServerEvent_SessionAttached)(nil), // 21: pluggableharness.frontend.v1.ServerEvent.SessionAttached - (*ServerEvent_BackfillComplete)(nil), // 22: pluggableharness.frontend.v1.ServerEvent.BackfillComplete - (*ServerEvent_SessionDetached)(nil), // 23: pluggableharness.frontend.v1.ServerEvent.SessionDetached - (*ServerEvent_SessionList)(nil), // 24: pluggableharness.frontend.v1.ServerEvent.SessionList - (*ServerEvent_SlashCommandRegistry)(nil), // 25: pluggableharness.frontend.v1.ServerEvent.SlashCommandRegistry - (*ServerEvent_UsageUpdate)(nil), // 26: pluggableharness.frontend.v1.ServerEvent.UsageUpdate - (*ServerEvent_SessionStatusUpdate)(nil), // 27: pluggableharness.frontend.v1.ServerEvent.SessionStatusUpdate - (*ClientEvent_UserMessage)(nil), // 28: pluggableharness.frontend.v1.ClientEvent.UserMessage - (*ClientEvent_SlashCommand)(nil), // 29: pluggableharness.frontend.v1.ClientEvent.SlashCommand - (*ClientEvent_PlanDecision)(nil), // 30: pluggableharness.frontend.v1.ClientEvent.PlanDecision - (*ClientEvent_InteractiveResponse)(nil), // 31: pluggableharness.frontend.v1.ClientEvent.InteractiveResponse - (*ClientEvent_ActionTrigger)(nil), // 32: pluggableharness.frontend.v1.ClientEvent.ActionTrigger - (*ClientEvent_Interrupt)(nil), // 33: pluggableharness.frontend.v1.ClientEvent.Interrupt - (*ClientEvent_Hello)(nil), // 34: pluggableharness.frontend.v1.ClientEvent.Hello - (*ClientEvent_CreateSession)(nil), // 35: pluggableharness.frontend.v1.ClientEvent.CreateSession - (*ClientEvent_AttachSession)(nil), // 36: pluggableharness.frontend.v1.ClientEvent.AttachSession - (*ClientEvent_ResumeSession)(nil), // 37: pluggableharness.frontend.v1.ClientEvent.ResumeSession - (*ClientEvent_DetachSession)(nil), // 38: pluggableharness.frontend.v1.ClientEvent.DetachSession - (*ClientEvent_ListSessions)(nil), // 39: pluggableharness.frontend.v1.ClientEvent.ListSessions - (*v1.ProducerRef)(nil), // 40: pluggableharness.common.v1.ProducerRef - (*v11.SlashCommandSpec)(nil), // 41: pluggableharness.slashcommand.v1.SlashCommandSpec - (*v12.ConfigSchema)(nil), // 42: pluggableharness.config.v1.ConfigSchema - (v13.Region)(0), // 43: pluggableharness.render.v1.Region - (v1.HookPoint)(0), // 44: pluggableharness.common.v1.HookPoint - (*structpb.Struct)(nil), // 45: google.protobuf.Struct - (*v13.PlacedContent)(nil), // 46: pluggableharness.render.v1.PlacedContent - (*v14.PlanItem)(nil), // 47: pluggableharness.plan.v1.PlanItem - (*v14.Plan)(nil), // 48: pluggableharness.plan.v1.Plan - (*v13.RenderTree)(nil), // 49: pluggableharness.render.v1.RenderTree - (v15.SessionStatus)(0), // 50: pluggableharness.session.v1.SessionStatus - (*v15.SessionInfo)(nil), // 51: pluggableharness.session.v1.SessionInfo - (*v16.Usage)(nil), // 52: pluggableharness.model.v1.Usage - (*v17.ContentBlock)(nil), // 53: pluggableharness.content.v1.ContentBlock -} -var file_pluggableharness_frontend_v1_frontend_proto_depIdxs = []int32{ - 40, // 0: pluggableharness.frontend.v1.DescribeResponse.producer:type_name -> pluggableharness.common.v1.ProducerRef - 7, // 1: pluggableharness.frontend.v1.GetCapabilitiesResponse.capabilities:type_name -> pluggableharness.frontend.v1.FrontendCapabilities - 41, // 2: pluggableharness.frontend.v1.FrontendCapabilities.slash_commands:type_name -> pluggableharness.slashcommand.v1.SlashCommandSpec - 42, // 3: pluggableharness.frontend.v1.FrontendCapabilities.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema - 43, // 4: pluggableharness.frontend.v1.FrontendCapabilities.supported_regions:type_name -> pluggableharness.render.v1.Region - 44, // 5: pluggableharness.frontend.v1.FrontendCapabilities.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint - 45, // 6: pluggableharness.frontend.v1.ConfigureRequest.config:type_name -> google.protobuf.Struct - 13, // 7: pluggableharness.frontend.v1.ServerEvent.stream_delta:type_name -> pluggableharness.frontend.v1.ServerEvent.StreamDelta - 14, // 8: pluggableharness.frontend.v1.ServerEvent.render:type_name -> pluggableharness.frontend.v1.ServerEvent.Render - 15, // 9: pluggableharness.frontend.v1.ServerEvent.permission_request:type_name -> pluggableharness.frontend.v1.ServerEvent.PermissionRequest - 16, // 10: pluggableharness.frontend.v1.ServerEvent.plan_ready:type_name -> pluggableharness.frontend.v1.ServerEvent.PlanReady - 17, // 11: pluggableharness.frontend.v1.ServerEvent.interactive_request:type_name -> pluggableharness.frontend.v1.ServerEvent.InteractiveRequest - 18, // 12: pluggableharness.frontend.v1.ServerEvent.session_tree_update:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionTreeUpdate - 19, // 13: pluggableharness.frontend.v1.ServerEvent.error:type_name -> pluggableharness.frontend.v1.ServerEvent.Error - 20, // 14: pluggableharness.frontend.v1.ServerEvent.session_created:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionCreated - 21, // 15: pluggableharness.frontend.v1.ServerEvent.session_attached:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionAttached - 22, // 16: pluggableharness.frontend.v1.ServerEvent.backfill_complete:type_name -> pluggableharness.frontend.v1.ServerEvent.BackfillComplete - 23, // 17: pluggableharness.frontend.v1.ServerEvent.session_detached:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionDetached - 24, // 18: pluggableharness.frontend.v1.ServerEvent.session_list:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionList - 25, // 19: pluggableharness.frontend.v1.ServerEvent.slash_command_registry:type_name -> pluggableharness.frontend.v1.ServerEvent.SlashCommandRegistry - 26, // 20: pluggableharness.frontend.v1.ServerEvent.usage_update:type_name -> pluggableharness.frontend.v1.ServerEvent.UsageUpdate - 27, // 21: pluggableharness.frontend.v1.ServerEvent.session_status_update:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionStatusUpdate - 28, // 22: pluggableharness.frontend.v1.ClientEvent.user_message:type_name -> pluggableharness.frontend.v1.ClientEvent.UserMessage - 29, // 23: pluggableharness.frontend.v1.ClientEvent.slash_command:type_name -> pluggableharness.frontend.v1.ClientEvent.SlashCommand - 30, // 24: pluggableharness.frontend.v1.ClientEvent.plan_decision:type_name -> pluggableharness.frontend.v1.ClientEvent.PlanDecision - 31, // 25: pluggableharness.frontend.v1.ClientEvent.interactive_response:type_name -> pluggableharness.frontend.v1.ClientEvent.InteractiveResponse - 32, // 26: pluggableharness.frontend.v1.ClientEvent.action_trigger:type_name -> pluggableharness.frontend.v1.ClientEvent.ActionTrigger - 33, // 27: pluggableharness.frontend.v1.ClientEvent.interrupt:type_name -> pluggableharness.frontend.v1.ClientEvent.Interrupt - 34, // 28: pluggableharness.frontend.v1.ClientEvent.hello:type_name -> pluggableharness.frontend.v1.ClientEvent.Hello - 35, // 29: pluggableharness.frontend.v1.ClientEvent.create_session:type_name -> pluggableharness.frontend.v1.ClientEvent.CreateSession - 36, // 30: pluggableharness.frontend.v1.ClientEvent.attach_session:type_name -> pluggableharness.frontend.v1.ClientEvent.AttachSession - 37, // 31: pluggableharness.frontend.v1.ClientEvent.resume_session:type_name -> pluggableharness.frontend.v1.ClientEvent.ResumeSession - 38, // 32: pluggableharness.frontend.v1.ClientEvent.detach_session:type_name -> pluggableharness.frontend.v1.ClientEvent.DetachSession - 39, // 33: pluggableharness.frontend.v1.ClientEvent.list_sessions:type_name -> pluggableharness.frontend.v1.ClientEvent.ListSessions - 2, // 34: pluggableharness.frontend.v1.FrontendError.category:type_name -> pluggableharness.frontend.v1.FrontendErrorCategory - 46, // 35: pluggableharness.frontend.v1.ServerEvent.Render.content:type_name -> pluggableharness.render.v1.PlacedContent - 47, // 36: pluggableharness.frontend.v1.ServerEvent.PermissionRequest.plan_item:type_name -> pluggableharness.plan.v1.PlanItem - 48, // 37: pluggableharness.frontend.v1.ServerEvent.PlanReady.plan:type_name -> pluggableharness.plan.v1.Plan - 49, // 38: pluggableharness.frontend.v1.ServerEvent.InteractiveRequest.prompt:type_name -> pluggableharness.render.v1.RenderTree - 50, // 39: pluggableharness.frontend.v1.ServerEvent.SessionTreeUpdate.status:type_name -> pluggableharness.session.v1.SessionStatus - 12, // 40: pluggableharness.frontend.v1.ServerEvent.Error.error:type_name -> pluggableharness.frontend.v1.FrontendError - 51, // 41: pluggableharness.frontend.v1.ServerEvent.SessionCreated.info:type_name -> pluggableharness.session.v1.SessionInfo - 51, // 42: pluggableharness.frontend.v1.ServerEvent.SessionAttached.info:type_name -> pluggableharness.session.v1.SessionInfo - 51, // 43: pluggableharness.frontend.v1.ServerEvent.SessionList.sessions:type_name -> pluggableharness.session.v1.SessionInfo - 41, // 44: pluggableharness.frontend.v1.ServerEvent.SlashCommandRegistry.commands:type_name -> pluggableharness.slashcommand.v1.SlashCommandSpec - 52, // 45: pluggableharness.frontend.v1.ServerEvent.UsageUpdate.turn:type_name -> pluggableharness.model.v1.Usage - 50, // 46: pluggableharness.frontend.v1.ServerEvent.SessionStatusUpdate.status:type_name -> pluggableharness.session.v1.SessionStatus - 53, // 47: pluggableharness.frontend.v1.ClientEvent.UserMessage.content:type_name -> pluggableharness.content.v1.ContentBlock - 0, // 48: pluggableharness.frontend.v1.ClientEvent.PlanDecision.decision:type_name -> pluggableharness.frontend.v1.ClientDecision - 45, // 49: pluggableharness.frontend.v1.ClientEvent.PlanDecision.corrected_input:type_name -> google.protobuf.Struct - 1, // 50: pluggableharness.frontend.v1.ClientEvent.PlanDecision.scope:type_name -> pluggableharness.frontend.v1.PlanDecisionScope - 45, // 51: pluggableharness.frontend.v1.ClientEvent.InteractiveResponse.response:type_name -> google.protobuf.Struct - 45, // 52: pluggableharness.frontend.v1.ClientEvent.ActionTrigger.args:type_name -> google.protobuf.Struct - 50, // 53: pluggableharness.frontend.v1.ClientEvent.ListSessions.status:type_name -> pluggableharness.session.v1.SessionStatus - 5, // 54: pluggableharness.frontend.v1.FrontendService.GetCapabilities:input_type -> pluggableharness.frontend.v1.GetCapabilitiesRequest - 8, // 55: pluggableharness.frontend.v1.FrontendService.Configure:input_type -> pluggableharness.frontend.v1.ConfigureRequest - 11, // 56: pluggableharness.frontend.v1.FrontendService.Attach:input_type -> pluggableharness.frontend.v1.ClientEvent - 3, // 57: pluggableharness.frontend.v1.FrontendService.Describe:input_type -> pluggableharness.frontend.v1.DescribeRequest - 6, // 58: pluggableharness.frontend.v1.FrontendService.GetCapabilities:output_type -> pluggableharness.frontend.v1.GetCapabilitiesResponse - 9, // 59: pluggableharness.frontend.v1.FrontendService.Configure:output_type -> pluggableharness.frontend.v1.ConfigureResponse - 10, // 60: pluggableharness.frontend.v1.FrontendService.Attach:output_type -> pluggableharness.frontend.v1.ServerEvent - 4, // 61: pluggableharness.frontend.v1.FrontendService.Describe:output_type -> pluggableharness.frontend.v1.DescribeResponse - 58, // [58:62] is the sub-list for method output_type - 54, // [54:58] is the sub-list for method input_type - 54, // [54:54] is the sub-list for extension type_name - 54, // [54:54] is the sub-list for extension extendee - 0, // [0:54] is the sub-list for field type_name -} - -func init() { file_pluggableharness_frontend_v1_frontend_proto_init() } -func file_pluggableharness_frontend_v1_frontend_proto_init() { - if File_pluggableharness_frontend_v1_frontend_proto != nil { + (*ServerEvent)(nil), // 2: pluggableharness.frontend.v1.ServerEvent + (*ClientEvent)(nil), // 3: pluggableharness.frontend.v1.ClientEvent + (*ServerEvent_StreamDelta)(nil), // 4: pluggableharness.frontend.v1.ServerEvent.StreamDelta + (*ServerEvent_Render)(nil), // 5: pluggableharness.frontend.v1.ServerEvent.Render + (*ServerEvent_PermissionRequest)(nil), // 6: pluggableharness.frontend.v1.ServerEvent.PermissionRequest + (*ServerEvent_PlanReady)(nil), // 7: pluggableharness.frontend.v1.ServerEvent.PlanReady + (*ServerEvent_InteractiveRequest)(nil), // 8: pluggableharness.frontend.v1.ServerEvent.InteractiveRequest + (*ServerEvent_SessionTreeUpdate)(nil), // 9: pluggableharness.frontend.v1.ServerEvent.SessionTreeUpdate + (*ServerEvent_Error)(nil), // 10: pluggableharness.frontend.v1.ServerEvent.Error + (*ServerEvent_SessionCreated)(nil), // 11: pluggableharness.frontend.v1.ServerEvent.SessionCreated + (*ServerEvent_SessionAttached)(nil), // 12: pluggableharness.frontend.v1.ServerEvent.SessionAttached + (*ServerEvent_BackfillComplete)(nil), // 13: pluggableharness.frontend.v1.ServerEvent.BackfillComplete + (*ServerEvent_SessionDetached)(nil), // 14: pluggableharness.frontend.v1.ServerEvent.SessionDetached + (*ServerEvent_SessionList)(nil), // 15: pluggableharness.frontend.v1.ServerEvent.SessionList + (*ServerEvent_SlashCommandRegistry)(nil), // 16: pluggableharness.frontend.v1.ServerEvent.SlashCommandRegistry + (*ServerEvent_UsageUpdate)(nil), // 17: pluggableharness.frontend.v1.ServerEvent.UsageUpdate + (*ServerEvent_SessionStatusUpdate)(nil), // 18: pluggableharness.frontend.v1.ServerEvent.SessionStatusUpdate + (*ClientEvent_UserMessage)(nil), // 19: pluggableharness.frontend.v1.ClientEvent.UserMessage + (*ClientEvent_SlashCommand)(nil), // 20: pluggableharness.frontend.v1.ClientEvent.SlashCommand + (*ClientEvent_PlanDecision)(nil), // 21: pluggableharness.frontend.v1.ClientEvent.PlanDecision + (*ClientEvent_InteractiveResponse)(nil), // 22: pluggableharness.frontend.v1.ClientEvent.InteractiveResponse + (*ClientEvent_ActionTrigger)(nil), // 23: pluggableharness.frontend.v1.ClientEvent.ActionTrigger + (*ClientEvent_Interrupt)(nil), // 24: pluggableharness.frontend.v1.ClientEvent.Interrupt + (*ClientEvent_Hello)(nil), // 25: pluggableharness.frontend.v1.ClientEvent.Hello + (*ClientEvent_CreateSession)(nil), // 26: pluggableharness.frontend.v1.ClientEvent.CreateSession + (*ClientEvent_AttachSession)(nil), // 27: pluggableharness.frontend.v1.ClientEvent.AttachSession + (*ClientEvent_ResumeSession)(nil), // 28: pluggableharness.frontend.v1.ClientEvent.ResumeSession + (*ClientEvent_DetachSession)(nil), // 29: pluggableharness.frontend.v1.ClientEvent.DetachSession + (*ClientEvent_ListSessions)(nil), // 30: pluggableharness.frontend.v1.ClientEvent.ListSessions + (*v1.PlacedContent)(nil), // 31: pluggableharness.render.v1.PlacedContent + (*v11.PlanItem)(nil), // 32: pluggableharness.plan.v1.PlanItem + (*v11.Plan)(nil), // 33: pluggableharness.plan.v1.Plan + (*v1.RenderTree)(nil), // 34: pluggableharness.render.v1.RenderTree + (v12.SessionStatus)(0), // 35: pluggableharness.session.v1.SessionStatus + (*FrontendError)(nil), // 36: pluggableharness.frontend.v1.FrontendError + (*v12.SessionInfo)(nil), // 37: pluggableharness.session.v1.SessionInfo + (*v13.SlashCommandSpec)(nil), // 38: pluggableharness.slashcommand.v1.SlashCommandSpec + (*v14.PromptExpansionSpec)(nil), // 39: pluggableharness.common.v1.PromptExpansionSpec + (*v15.Usage)(nil), // 40: pluggableharness.model.v1.Usage + (*v16.ContentBlock)(nil), // 41: pluggableharness.content.v1.ContentBlock + (*structpb.Struct)(nil), // 42: google.protobuf.Struct +} +var file_pluggableharness_frontend_v1_events_proto_depIdxs = []int32{ + 4, // 0: pluggableharness.frontend.v1.ServerEvent.stream_delta:type_name -> pluggableharness.frontend.v1.ServerEvent.StreamDelta + 5, // 1: pluggableharness.frontend.v1.ServerEvent.render:type_name -> pluggableharness.frontend.v1.ServerEvent.Render + 6, // 2: pluggableharness.frontend.v1.ServerEvent.permission_request:type_name -> pluggableharness.frontend.v1.ServerEvent.PermissionRequest + 7, // 3: pluggableharness.frontend.v1.ServerEvent.plan_ready:type_name -> pluggableharness.frontend.v1.ServerEvent.PlanReady + 8, // 4: pluggableharness.frontend.v1.ServerEvent.interactive_request:type_name -> pluggableharness.frontend.v1.ServerEvent.InteractiveRequest + 9, // 5: pluggableharness.frontend.v1.ServerEvent.session_tree_update:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionTreeUpdate + 10, // 6: pluggableharness.frontend.v1.ServerEvent.error:type_name -> pluggableharness.frontend.v1.ServerEvent.Error + 11, // 7: pluggableharness.frontend.v1.ServerEvent.session_created:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionCreated + 12, // 8: pluggableharness.frontend.v1.ServerEvent.session_attached:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionAttached + 13, // 9: pluggableharness.frontend.v1.ServerEvent.backfill_complete:type_name -> pluggableharness.frontend.v1.ServerEvent.BackfillComplete + 14, // 10: pluggableharness.frontend.v1.ServerEvent.session_detached:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionDetached + 15, // 11: pluggableharness.frontend.v1.ServerEvent.session_list:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionList + 16, // 12: pluggableharness.frontend.v1.ServerEvent.slash_command_registry:type_name -> pluggableharness.frontend.v1.ServerEvent.SlashCommandRegistry + 17, // 13: pluggableharness.frontend.v1.ServerEvent.usage_update:type_name -> pluggableharness.frontend.v1.ServerEvent.UsageUpdate + 18, // 14: pluggableharness.frontend.v1.ServerEvent.session_status_update:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionStatusUpdate + 19, // 15: pluggableharness.frontend.v1.ClientEvent.user_message:type_name -> pluggableharness.frontend.v1.ClientEvent.UserMessage + 20, // 16: pluggableharness.frontend.v1.ClientEvent.slash_command:type_name -> pluggableharness.frontend.v1.ClientEvent.SlashCommand + 21, // 17: pluggableharness.frontend.v1.ClientEvent.plan_decision:type_name -> pluggableharness.frontend.v1.ClientEvent.PlanDecision + 22, // 18: pluggableharness.frontend.v1.ClientEvent.interactive_response:type_name -> pluggableharness.frontend.v1.ClientEvent.InteractiveResponse + 23, // 19: pluggableharness.frontend.v1.ClientEvent.action_trigger:type_name -> pluggableharness.frontend.v1.ClientEvent.ActionTrigger + 24, // 20: pluggableharness.frontend.v1.ClientEvent.interrupt:type_name -> pluggableharness.frontend.v1.ClientEvent.Interrupt + 25, // 21: pluggableharness.frontend.v1.ClientEvent.hello:type_name -> pluggableharness.frontend.v1.ClientEvent.Hello + 26, // 22: pluggableharness.frontend.v1.ClientEvent.create_session:type_name -> pluggableharness.frontend.v1.ClientEvent.CreateSession + 27, // 23: pluggableharness.frontend.v1.ClientEvent.attach_session:type_name -> pluggableharness.frontend.v1.ClientEvent.AttachSession + 28, // 24: pluggableharness.frontend.v1.ClientEvent.resume_session:type_name -> pluggableharness.frontend.v1.ClientEvent.ResumeSession + 29, // 25: pluggableharness.frontend.v1.ClientEvent.detach_session:type_name -> pluggableharness.frontend.v1.ClientEvent.DetachSession + 30, // 26: pluggableharness.frontend.v1.ClientEvent.list_sessions:type_name -> pluggableharness.frontend.v1.ClientEvent.ListSessions + 31, // 27: pluggableharness.frontend.v1.ServerEvent.Render.content:type_name -> pluggableharness.render.v1.PlacedContent + 32, // 28: pluggableharness.frontend.v1.ServerEvent.PermissionRequest.plan_item:type_name -> pluggableharness.plan.v1.PlanItem + 33, // 29: pluggableharness.frontend.v1.ServerEvent.PlanReady.plan:type_name -> pluggableharness.plan.v1.Plan + 34, // 30: pluggableharness.frontend.v1.ServerEvent.InteractiveRequest.prompt:type_name -> pluggableharness.render.v1.RenderTree + 35, // 31: pluggableharness.frontend.v1.ServerEvent.SessionTreeUpdate.status:type_name -> pluggableharness.session.v1.SessionStatus + 36, // 32: pluggableharness.frontend.v1.ServerEvent.Error.error:type_name -> pluggableharness.frontend.v1.FrontendError + 37, // 33: pluggableharness.frontend.v1.ServerEvent.SessionCreated.info:type_name -> pluggableharness.session.v1.SessionInfo + 37, // 34: pluggableharness.frontend.v1.ServerEvent.SessionAttached.info:type_name -> pluggableharness.session.v1.SessionInfo + 37, // 35: pluggableharness.frontend.v1.ServerEvent.SessionList.sessions:type_name -> pluggableharness.session.v1.SessionInfo + 38, // 36: pluggableharness.frontend.v1.ServerEvent.SlashCommandRegistry.direct_invoke_commands:type_name -> pluggableharness.slashcommand.v1.SlashCommandSpec + 39, // 37: pluggableharness.frontend.v1.ServerEvent.SlashCommandRegistry.prompt_expansion_commands:type_name -> pluggableharness.common.v1.PromptExpansionSpec + 40, // 38: pluggableharness.frontend.v1.ServerEvent.UsageUpdate.turn:type_name -> pluggableharness.model.v1.Usage + 35, // 39: pluggableharness.frontend.v1.ServerEvent.SessionStatusUpdate.status:type_name -> pluggableharness.session.v1.SessionStatus + 41, // 40: pluggableharness.frontend.v1.ClientEvent.UserMessage.content:type_name -> pluggableharness.content.v1.ContentBlock + 0, // 41: pluggableharness.frontend.v1.ClientEvent.PlanDecision.decision:type_name -> pluggableharness.frontend.v1.ClientDecision + 42, // 42: pluggableharness.frontend.v1.ClientEvent.PlanDecision.corrected_input:type_name -> google.protobuf.Struct + 1, // 43: pluggableharness.frontend.v1.ClientEvent.PlanDecision.scope:type_name -> pluggableharness.frontend.v1.PlanDecisionScope + 42, // 44: pluggableharness.frontend.v1.ClientEvent.InteractiveResponse.response:type_name -> google.protobuf.Struct + 42, // 45: pluggableharness.frontend.v1.ClientEvent.ActionTrigger.args:type_name -> google.protobuf.Struct + 35, // 46: pluggableharness.frontend.v1.ClientEvent.ListSessions.status:type_name -> pluggableharness.session.v1.SessionStatus + 47, // [47:47] is the sub-list for method output_type + 47, // [47:47] is the sub-list for method input_type + 47, // [47:47] is the sub-list for extension type_name + 47, // [47:47] is the sub-list for extension extendee + 0, // [0:47] is the sub-list for field type_name +} + +func init() { file_pluggableharness_frontend_v1_events_proto_init() } +func file_pluggableharness_frontend_v1_events_proto_init() { + if File_pluggableharness_frontend_v1_events_proto != nil { return } - file_pluggableharness_frontend_v1_frontend_proto_msgTypes[7].OneofWrappers = []any{ + file_pluggableharness_frontend_v1_errors_proto_init() + file_pluggableharness_frontend_v1_events_proto_msgTypes[0].OneofWrappers = []any{ (*ServerEvent_StreamDelta_)(nil), (*ServerEvent_Render_)(nil), (*ServerEvent_PermissionRequest_)(nil), @@ -3136,7 +2601,7 @@ func file_pluggableharness_frontend_v1_frontend_proto_init() { (*ServerEvent_UsageUpdate_)(nil), (*ServerEvent_SessionStatusUpdate_)(nil), } - file_pluggableharness_frontend_v1_frontend_proto_msgTypes[8].OneofWrappers = []any{ + file_pluggableharness_frontend_v1_events_proto_msgTypes[1].OneofWrappers = []any{ (*ClientEvent_UserMessage_)(nil), (*ClientEvent_SlashCommand_)(nil), (*ClientEvent_PlanDecision_)(nil), @@ -3150,25 +2615,25 @@ func file_pluggableharness_frontend_v1_frontend_proto_init() { (*ClientEvent_DetachSession_)(nil), (*ClientEvent_ListSessions_)(nil), } - file_pluggableharness_frontend_v1_frontend_proto_msgTypes[27].OneofWrappers = []any{} - file_pluggableharness_frontend_v1_frontend_proto_msgTypes[32].OneofWrappers = []any{} - file_pluggableharness_frontend_v1_frontend_proto_msgTypes[36].OneofWrappers = []any{} + file_pluggableharness_frontend_v1_events_proto_msgTypes[19].OneofWrappers = []any{} + file_pluggableharness_frontend_v1_events_proto_msgTypes[24].OneofWrappers = []any{} + file_pluggableharness_frontend_v1_events_proto_msgTypes[28].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_frontend_v1_frontend_proto_rawDesc), len(file_pluggableharness_frontend_v1_frontend_proto_rawDesc)), - NumEnums: 3, - NumMessages: 37, + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_frontend_v1_events_proto_rawDesc), len(file_pluggableharness_frontend_v1_events_proto_rawDesc)), + NumEnums: 2, + NumMessages: 29, NumExtensions: 0, - NumServices: 1, + NumServices: 0, }, - GoTypes: file_pluggableharness_frontend_v1_frontend_proto_goTypes, - DependencyIndexes: file_pluggableharness_frontend_v1_frontend_proto_depIdxs, - EnumInfos: file_pluggableharness_frontend_v1_frontend_proto_enumTypes, - MessageInfos: file_pluggableharness_frontend_v1_frontend_proto_msgTypes, + GoTypes: file_pluggableharness_frontend_v1_events_proto_goTypes, + DependencyIndexes: file_pluggableharness_frontend_v1_events_proto_depIdxs, + EnumInfos: file_pluggableharness_frontend_v1_events_proto_enumTypes, + MessageInfos: file_pluggableharness_frontend_v1_events_proto_msgTypes, }.Build() - File_pluggableharness_frontend_v1_frontend_proto = out.File - file_pluggableharness_frontend_v1_frontend_proto_goTypes = nil - file_pluggableharness_frontend_v1_frontend_proto_depIdxs = nil + File_pluggableharness_frontend_v1_events_proto = out.File + file_pluggableharness_frontend_v1_events_proto_goTypes = nil + file_pluggableharness_frontend_v1_events_proto_depIdxs = nil } diff --git a/pkg/frontend/proto/v1/rpc_request.pb.go b/pkg/frontend/proto/v1/rpc_request.pb.go new file mode 100644 index 0000000..c1253c2 --- /dev/null +++ b/pkg/frontend/proto/v1/rpc_request.pb.go @@ -0,0 +1,208 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/frontend/v1/rpc_request.proto + +package frontendv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// GetCapabilitiesRequest carries no fields; capability discovery is not +// parameterized. +type GetCapabilitiesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCapabilitiesRequest) Reset() { + *x = GetCapabilitiesRequest{} + mi := &file_pluggableharness_frontend_v1_rpc_request_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCapabilitiesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCapabilitiesRequest) ProtoMessage() {} + +func (x *GetCapabilitiesRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_frontend_v1_rpc_request_proto_msgTypes[0] + 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 GetCapabilitiesRequest.ProtoReflect.Descriptor instead. +func (*GetCapabilitiesRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_frontend_v1_rpc_request_proto_rawDescGZIP(), []int{0} +} + +// ConfigureRequest carries this provider's `agent.hcl` configuration as a +// dynamic Struct, shaped per the ConfigSchema returned by GetCapabilities +// (configuration.md §4). +type ConfigureRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The configuration value, validated against this provider's ConfigSchema. + Config *structpb.Struct `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureRequest) Reset() { + *x = ConfigureRequest{} + mi := &file_pluggableharness_frontend_v1_rpc_request_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureRequest) ProtoMessage() {} + +func (x *ConfigureRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_frontend_v1_rpc_request_proto_msgTypes[1] + 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 ConfigureRequest.ProtoReflect.Descriptor instead. +func (*ConfigureRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_frontend_v1_rpc_request_proto_rawDescGZIP(), []int{1} +} + +func (x *ConfigureRequest) GetConfig() *structpb.Struct { + if x != nil { + return x.Config + } + return nil +} + +// DescribeRequest carries no fields — Describe takes no parameters. +type DescribeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeRequest) Reset() { + *x = DescribeRequest{} + mi := &file_pluggableharness_frontend_v1_rpc_request_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeRequest) ProtoMessage() {} + +func (x *DescribeRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_frontend_v1_rpc_request_proto_msgTypes[2] + 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 DescribeRequest.ProtoReflect.Descriptor instead. +func (*DescribeRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_frontend_v1_rpc_request_proto_rawDescGZIP(), []int{2} +} + +var File_pluggableharness_frontend_v1_rpc_request_proto protoreflect.FileDescriptor + +const file_pluggableharness_frontend_v1_rpc_request_proto_rawDesc = "" + + "\n" + + ".pluggableharness/frontend/v1/rpc_request.proto\x12\x1cpluggableharness.frontend.v1\x1a\x1cgoogle/protobuf/struct.proto\"\x18\n" + + "\x16GetCapabilitiesRequest\"C\n" + + "\x10ConfigureRequest\x12/\n" + + "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06config\"\x11\n" + + "\x0fDescribeRequestBDZBgithub.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1b\x06proto3" + +var ( + file_pluggableharness_frontend_v1_rpc_request_proto_rawDescOnce sync.Once + file_pluggableharness_frontend_v1_rpc_request_proto_rawDescData []byte +) + +func file_pluggableharness_frontend_v1_rpc_request_proto_rawDescGZIP() []byte { + file_pluggableharness_frontend_v1_rpc_request_proto_rawDescOnce.Do(func() { + file_pluggableharness_frontend_v1_rpc_request_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_frontend_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_frontend_v1_rpc_request_proto_rawDesc))) + }) + return file_pluggableharness_frontend_v1_rpc_request_proto_rawDescData +} + +var file_pluggableharness_frontend_v1_rpc_request_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_pluggableharness_frontend_v1_rpc_request_proto_goTypes = []any{ + (*GetCapabilitiesRequest)(nil), // 0: pluggableharness.frontend.v1.GetCapabilitiesRequest + (*ConfigureRequest)(nil), // 1: pluggableharness.frontend.v1.ConfigureRequest + (*DescribeRequest)(nil), // 2: pluggableharness.frontend.v1.DescribeRequest + (*structpb.Struct)(nil), // 3: google.protobuf.Struct +} +var file_pluggableharness_frontend_v1_rpc_request_proto_depIdxs = []int32{ + 3, // 0: pluggableharness.frontend.v1.ConfigureRequest.config:type_name -> google.protobuf.Struct + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_pluggableharness_frontend_v1_rpc_request_proto_init() } +func file_pluggableharness_frontend_v1_rpc_request_proto_init() { + if File_pluggableharness_frontend_v1_rpc_request_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_frontend_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_frontend_v1_rpc_request_proto_rawDesc)), + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_frontend_v1_rpc_request_proto_goTypes, + DependencyIndexes: file_pluggableharness_frontend_v1_rpc_request_proto_depIdxs, + MessageInfos: file_pluggableharness_frontend_v1_rpc_request_proto_msgTypes, + }.Build() + File_pluggableharness_frontend_v1_rpc_request_proto = out.File + file_pluggableharness_frontend_v1_rpc_request_proto_goTypes = nil + file_pluggableharness_frontend_v1_rpc_request_proto_depIdxs = nil +} diff --git a/pkg/frontend/proto/v1/rpc_response.pb.go b/pkg/frontend/proto/v1/rpc_response.pb.go new file mode 100644 index 0000000..d98b50d --- /dev/null +++ b/pkg/frontend/proto/v1/rpc_response.pb.go @@ -0,0 +1,222 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/frontend/v1/rpc_response.proto + +package frontendv1 + +import ( + v1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// GetCapabilitiesResponse wraps FrontendCapabilities for the RPC signature, +// per this repo's per-RPC envelope convention (.claude/rules/proto.md). +type GetCapabilitiesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Capabilities *FrontendCapabilities `protobuf:"bytes,1,opt,name=capabilities,proto3" json:"capabilities,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCapabilitiesResponse) Reset() { + *x = GetCapabilitiesResponse{} + mi := &file_pluggableharness_frontend_v1_rpc_response_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCapabilitiesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCapabilitiesResponse) ProtoMessage() {} + +func (x *GetCapabilitiesResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_frontend_v1_rpc_response_proto_msgTypes[0] + 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 GetCapabilitiesResponse.ProtoReflect.Descriptor instead. +func (*GetCapabilitiesResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_frontend_v1_rpc_response_proto_rawDescGZIP(), []int{0} +} + +func (x *GetCapabilitiesResponse) GetCapabilities() *FrontendCapabilities { + if x != nil { + return x.Capabilities + } + return nil +} + +// ConfigureResponse is empty on success. Configuration errors surface as a +// gRPC status carrying a FrontendError in its structured detail +// (.claude/rules/grpc.md), not as an in-band field on this message. +type ConfigureResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureResponse) Reset() { + *x = ConfigureResponse{} + mi := &file_pluggableharness_frontend_v1_rpc_response_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureResponse) ProtoMessage() {} + +func (x *ConfigureResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_frontend_v1_rpc_response_proto_msgTypes[1] + 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 ConfigureResponse.ProtoReflect.Descriptor instead. +func (*ConfigureResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_frontend_v1_rpc_response_proto_rawDescGZIP(), []int{1} +} + +// DescribeResponse reports this plugin build's own identity, obtained +// directly from the running process rather than a lock-file row — +// configuration/lock-file.md's "dev_overrides and identity without a lock +// entry". +type DescribeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Producer *v1.ProducerRef `protobuf:"bytes,1,opt,name=producer,proto3" json:"producer,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeResponse) Reset() { + *x = DescribeResponse{} + mi := &file_pluggableharness_frontend_v1_rpc_response_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeResponse) ProtoMessage() {} + +func (x *DescribeResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_frontend_v1_rpc_response_proto_msgTypes[2] + 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 DescribeResponse.ProtoReflect.Descriptor instead. +func (*DescribeResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_frontend_v1_rpc_response_proto_rawDescGZIP(), []int{2} +} + +func (x *DescribeResponse) GetProducer() *v1.ProducerRef { + if x != nil { + return x.Producer + } + return nil +} + +var File_pluggableharness_frontend_v1_rpc_response_proto protoreflect.FileDescriptor + +const file_pluggableharness_frontend_v1_rpc_response_proto_rawDesc = "" + + "\n" + + "/pluggableharness/frontend/v1/rpc_response.proto\x12\x1cpluggableharness.frontend.v1\x1a&pluggableharness/common/v1/types.proto\x1a(pluggableharness/frontend/v1/types.proto\"q\n" + + "\x17GetCapabilitiesResponse\x12V\n" + + "\fcapabilities\x18\x01 \x01(\v22.pluggableharness.frontend.v1.FrontendCapabilitiesR\fcapabilities\"\x13\n" + + "\x11ConfigureResponse\"W\n" + + "\x10DescribeResponse\x12C\n" + + "\bproducer\x18\x01 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\bproducerBDZBgithub.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1b\x06proto3" + +var ( + file_pluggableharness_frontend_v1_rpc_response_proto_rawDescOnce sync.Once + file_pluggableharness_frontend_v1_rpc_response_proto_rawDescData []byte +) + +func file_pluggableharness_frontend_v1_rpc_response_proto_rawDescGZIP() []byte { + file_pluggableharness_frontend_v1_rpc_response_proto_rawDescOnce.Do(func() { + file_pluggableharness_frontend_v1_rpc_response_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_frontend_v1_rpc_response_proto_rawDesc), len(file_pluggableharness_frontend_v1_rpc_response_proto_rawDesc))) + }) + return file_pluggableharness_frontend_v1_rpc_response_proto_rawDescData +} + +var file_pluggableharness_frontend_v1_rpc_response_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_pluggableharness_frontend_v1_rpc_response_proto_goTypes = []any{ + (*GetCapabilitiesResponse)(nil), // 0: pluggableharness.frontend.v1.GetCapabilitiesResponse + (*ConfigureResponse)(nil), // 1: pluggableharness.frontend.v1.ConfigureResponse + (*DescribeResponse)(nil), // 2: pluggableharness.frontend.v1.DescribeResponse + (*FrontendCapabilities)(nil), // 3: pluggableharness.frontend.v1.FrontendCapabilities + (*v1.ProducerRef)(nil), // 4: pluggableharness.common.v1.ProducerRef +} +var file_pluggableharness_frontend_v1_rpc_response_proto_depIdxs = []int32{ + 3, // 0: pluggableharness.frontend.v1.GetCapabilitiesResponse.capabilities:type_name -> pluggableharness.frontend.v1.FrontendCapabilities + 4, // 1: pluggableharness.frontend.v1.DescribeResponse.producer:type_name -> pluggableharness.common.v1.ProducerRef + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_pluggableharness_frontend_v1_rpc_response_proto_init() } +func file_pluggableharness_frontend_v1_rpc_response_proto_init() { + if File_pluggableharness_frontend_v1_rpc_response_proto != nil { + return + } + file_pluggableharness_frontend_v1_types_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_frontend_v1_rpc_response_proto_rawDesc), len(file_pluggableharness_frontend_v1_rpc_response_proto_rawDesc)), + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_frontend_v1_rpc_response_proto_goTypes, + DependencyIndexes: file_pluggableharness_frontend_v1_rpc_response_proto_depIdxs, + MessageInfos: file_pluggableharness_frontend_v1_rpc_response_proto_msgTypes, + }.Build() + File_pluggableharness_frontend_v1_rpc_response_proto = out.File + file_pluggableharness_frontend_v1_rpc_response_proto_goTypes = nil + file_pluggableharness_frontend_v1_rpc_response_proto_depIdxs = nil +} diff --git a/pkg/frontend/proto/v1/service.pb.go b/pkg/frontend/proto/v1/service.pb.go new file mode 100644 index 0000000..48efe48 --- /dev/null +++ b/pkg/frontend/proto/v1/service.pb.go @@ -0,0 +1,88 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/frontend/v1/service.proto + +// Package pluggableharness.frontend.v1 defines the frontend provider plugin protocol +// described in specifications/frontend.md §3 (Attach, ServerEvent, +// ClientEvent, ...). + +package frontendv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_pluggableharness_frontend_v1_service_proto protoreflect.FileDescriptor + +const file_pluggableharness_frontend_v1_service_proto_rawDesc = "" + + "\n" + + "*pluggableharness/frontend/v1/service.proto\x12\x1cpluggableharness.frontend.v1\x1a)pluggableharness/frontend/v1/events.proto\x1a.pluggableharness/frontend/v1/rpc_request.proto\x1a/pluggableharness/frontend/v1/rpc_response.proto2\xce\x03\n" + + "\x0fFrontendService\x12~\n" + + "\x0fGetCapabilities\x124.pluggableharness.frontend.v1.GetCapabilitiesRequest\x1a5.pluggableharness.frontend.v1.GetCapabilitiesResponse\x12l\n" + + "\tConfigure\x12..pluggableharness.frontend.v1.ConfigureRequest\x1a/.pluggableharness.frontend.v1.ConfigureResponse\x12b\n" + + "\x06Attach\x12).pluggableharness.frontend.v1.ClientEvent\x1a).pluggableharness.frontend.v1.ServerEvent(\x010\x01\x12i\n" + + "\bDescribe\x12-.pluggableharness.frontend.v1.DescribeRequest\x1a..pluggableharness.frontend.v1.DescribeResponseBDZBgithub.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1b\x06proto3" + +var file_pluggableharness_frontend_v1_service_proto_goTypes = []any{ + (*GetCapabilitiesRequest)(nil), // 0: pluggableharness.frontend.v1.GetCapabilitiesRequest + (*ConfigureRequest)(nil), // 1: pluggableharness.frontend.v1.ConfigureRequest + (*ClientEvent)(nil), // 2: pluggableharness.frontend.v1.ClientEvent + (*DescribeRequest)(nil), // 3: pluggableharness.frontend.v1.DescribeRequest + (*GetCapabilitiesResponse)(nil), // 4: pluggableharness.frontend.v1.GetCapabilitiesResponse + (*ConfigureResponse)(nil), // 5: pluggableharness.frontend.v1.ConfigureResponse + (*ServerEvent)(nil), // 6: pluggableharness.frontend.v1.ServerEvent + (*DescribeResponse)(nil), // 7: pluggableharness.frontend.v1.DescribeResponse +} +var file_pluggableharness_frontend_v1_service_proto_depIdxs = []int32{ + 0, // 0: pluggableharness.frontend.v1.FrontendService.GetCapabilities:input_type -> pluggableharness.frontend.v1.GetCapabilitiesRequest + 1, // 1: pluggableharness.frontend.v1.FrontendService.Configure:input_type -> pluggableharness.frontend.v1.ConfigureRequest + 2, // 2: pluggableharness.frontend.v1.FrontendService.Attach:input_type -> pluggableharness.frontend.v1.ClientEvent + 3, // 3: pluggableharness.frontend.v1.FrontendService.Describe:input_type -> pluggableharness.frontend.v1.DescribeRequest + 4, // 4: pluggableharness.frontend.v1.FrontendService.GetCapabilities:output_type -> pluggableharness.frontend.v1.GetCapabilitiesResponse + 5, // 5: pluggableharness.frontend.v1.FrontendService.Configure:output_type -> pluggableharness.frontend.v1.ConfigureResponse + 6, // 6: pluggableharness.frontend.v1.FrontendService.Attach:output_type -> pluggableharness.frontend.v1.ServerEvent + 7, // 7: pluggableharness.frontend.v1.FrontendService.Describe:output_type -> pluggableharness.frontend.v1.DescribeResponse + 4, // [4:8] is the sub-list for method output_type + 0, // [0:4] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_pluggableharness_frontend_v1_service_proto_init() } +func file_pluggableharness_frontend_v1_service_proto_init() { + if File_pluggableharness_frontend_v1_service_proto != nil { + return + } + file_pluggableharness_frontend_v1_events_proto_init() + file_pluggableharness_frontend_v1_rpc_request_proto_init() + file_pluggableharness_frontend_v1_rpc_response_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_frontend_v1_service_proto_rawDesc), len(file_pluggableharness_frontend_v1_service_proto_rawDesc)), + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_pluggableharness_frontend_v1_service_proto_goTypes, + DependencyIndexes: file_pluggableharness_frontend_v1_service_proto_depIdxs, + }.Build() + File_pluggableharness_frontend_v1_service_proto = out.File + file_pluggableharness_frontend_v1_service_proto_goTypes = nil + file_pluggableharness_frontend_v1_service_proto_depIdxs = nil +} diff --git a/pkg/frontend/proto/v1/frontend_grpc.pb.go b/pkg/frontend/proto/v1/service_grpc.pb.go similarity index 98% rename from pkg/frontend/proto/v1/frontend_grpc.pb.go rename to pkg/frontend/proto/v1/service_grpc.pb.go index 491c941..94679cc 100644 --- a/pkg/frontend/proto/v1/frontend_grpc.pb.go +++ b/pkg/frontend/proto/v1/service_grpc.pb.go @@ -2,7 +2,7 @@ // versions: // - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) -// source: pluggableharness/frontend/v1/frontend.proto +// source: pluggableharness/frontend/v1/service.proto // Package pluggableharness.frontend.v1 defines the frontend provider plugin protocol // described in specifications/frontend.md §3 (Attach, ServerEvent, @@ -79,7 +79,7 @@ type FrontendServiceClient interface { // Describe reports this plugin build's own identity — {name, version, // source, category, protocol_version} — directly from the running // process, rather than the kernel inferring it from a lock-file row. - // Every one of the six category protocols gains this identical RPC in + // Every one of the seven category protocols gains this identical RPC in // this protocol revision; it exists specifically for a // `dev_overrides`-resolved binary (configuration/lock-file.md's // "dev_overrides and identity without a lock entry"), which has no @@ -188,7 +188,7 @@ type FrontendServiceServer interface { // Describe reports this plugin build's own identity — {name, version, // source, category, protocol_version} — directly from the running // process, rather than the kernel inferring it from a lock-file row. - // Every one of the six category protocols gains this identical RPC in + // Every one of the seven category protocols gains this identical RPC in // this protocol revision; it exists specifically for a // `dev_overrides`-resolved binary (configuration/lock-file.md's // "dev_overrides and identity without a lock entry"), which has no @@ -326,5 +326,5 @@ var FrontendService_ServiceDesc = grpc.ServiceDesc{ ClientStreams: true, }, }, - Metadata: "pluggableharness/frontend/v1/frontend.proto", + Metadata: "pluggableharness/frontend/v1/service.proto", } diff --git a/pkg/frontend/proto/v1/types.pb.go b/pkg/frontend/proto/v1/types.pb.go new file mode 100644 index 0000000..74d8c89 --- /dev/null +++ b/pkg/frontend/proto/v1/types.pb.go @@ -0,0 +1,175 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/frontend/v1/types.proto + +package frontendv1 + +import ( + v1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + v11 "github.com/pluggableharness/agent/pkg/config/proto/v1" + v12 "github.com/pluggableharness/agent/pkg/render/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// FrontendCapabilities is this frontend's static self-description, returned +// by GetCapabilities (frontend.md §3.1). +type FrontendCapabilities struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Prompt-expansion slash commands this frontend contributes. MAY be + // empty. A direct-invoke command is declared by a slashcommand.v1 + // provider instead (specifications/slashcommand/), never here. + SlashCommands []*v1.PromptExpansionSpec `protobuf:"bytes,1,rep,name=slash_commands,json=slashCommands,proto3" json:"slash_commands,omitempty"` + // This provider's `agent.hcl` configuration schema (configuration.md §4). + ConfigSchema *v11.ConfigSchema `protobuf:"bytes,2,opt,name=config_schema,json=configSchema,proto3" json:"config_schema,omitempty"` + // Regions this frontend proactively declares it can render into. A + // complement to, not a replacement for, the reactive + // FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED error a placement the + // frontend can't honor still produces — this lets a producer route + // content preferentially without waiting to find out the hard way. + SupportedRegions []v12.Region `protobuf:"varint,3,rep,packed,name=supported_regions,json=supportedRegions,proto3,enum=pluggableharness.render.v1.Region" json:"supported_regions,omitempty"` + // Hook points this frontend can subscribe to (agent-loop/hook-dispatch.md), + // so a mis-declared agent.hcl hook{} block naming an unsupported point + // can be rejected at config-load time rather than failing at first + // dispatch. + SupportedHookPoints []v1.HookPoint `protobuf:"varint,4,rep,packed,name=supported_hook_points,json=supportedHookPoints,proto3,enum=pluggableharness.common.v1.HookPoint" json:"supported_hook_points,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FrontendCapabilities) Reset() { + *x = FrontendCapabilities{} + mi := &file_pluggableharness_frontend_v1_types_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FrontendCapabilities) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FrontendCapabilities) ProtoMessage() {} + +func (x *FrontendCapabilities) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_frontend_v1_types_proto_msgTypes[0] + 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 FrontendCapabilities.ProtoReflect.Descriptor instead. +func (*FrontendCapabilities) Descriptor() ([]byte, []int) { + return file_pluggableharness_frontend_v1_types_proto_rawDescGZIP(), []int{0} +} + +func (x *FrontendCapabilities) GetSlashCommands() []*v1.PromptExpansionSpec { + if x != nil { + return x.SlashCommands + } + return nil +} + +func (x *FrontendCapabilities) GetConfigSchema() *v11.ConfigSchema { + if x != nil { + return x.ConfigSchema + } + return nil +} + +func (x *FrontendCapabilities) GetSupportedRegions() []v12.Region { + if x != nil { + return x.SupportedRegions + } + return nil +} + +func (x *FrontendCapabilities) GetSupportedHookPoints() []v1.HookPoint { + if x != nil { + return x.SupportedHookPoints + } + return nil +} + +var File_pluggableharness_frontend_v1_types_proto protoreflect.FileDescriptor + +const file_pluggableharness_frontend_v1_types_proto_rawDesc = "" + + "\n" + + "(pluggableharness/frontend/v1/types.proto\x12\x1cpluggableharness.frontend.v1\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/config/v1/types.proto\x1a&pluggableharness/render/v1/types.proto\"\xe9\x02\n" + + "\x14FrontendCapabilities\x12V\n" + + "\x0eslash_commands\x18\x01 \x03(\v2/.pluggableharness.common.v1.PromptExpansionSpecR\rslashCommands\x12M\n" + + "\rconfig_schema\x18\x02 \x01(\v2(.pluggableharness.config.v1.ConfigSchemaR\fconfigSchema\x12O\n" + + "\x11supported_regions\x18\x03 \x03(\x0e2\".pluggableharness.render.v1.RegionR\x10supportedRegions\x12Y\n" + + "\x15supported_hook_points\x18\x04 \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPointsBDZBgithub.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1b\x06proto3" + +var ( + file_pluggableharness_frontend_v1_types_proto_rawDescOnce sync.Once + file_pluggableharness_frontend_v1_types_proto_rawDescData []byte +) + +func file_pluggableharness_frontend_v1_types_proto_rawDescGZIP() []byte { + file_pluggableharness_frontend_v1_types_proto_rawDescOnce.Do(func() { + file_pluggableharness_frontend_v1_types_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_frontend_v1_types_proto_rawDesc), len(file_pluggableharness_frontend_v1_types_proto_rawDesc))) + }) + return file_pluggableharness_frontend_v1_types_proto_rawDescData +} + +var file_pluggableharness_frontend_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_pluggableharness_frontend_v1_types_proto_goTypes = []any{ + (*FrontendCapabilities)(nil), // 0: pluggableharness.frontend.v1.FrontendCapabilities + (*v1.PromptExpansionSpec)(nil), // 1: pluggableharness.common.v1.PromptExpansionSpec + (*v11.ConfigSchema)(nil), // 2: pluggableharness.config.v1.ConfigSchema + (v12.Region)(0), // 3: pluggableharness.render.v1.Region + (v1.HookPoint)(0), // 4: pluggableharness.common.v1.HookPoint +} +var file_pluggableharness_frontend_v1_types_proto_depIdxs = []int32{ + 1, // 0: pluggableharness.frontend.v1.FrontendCapabilities.slash_commands:type_name -> pluggableharness.common.v1.PromptExpansionSpec + 2, // 1: pluggableharness.frontend.v1.FrontendCapabilities.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema + 3, // 2: pluggableharness.frontend.v1.FrontendCapabilities.supported_regions:type_name -> pluggableharness.render.v1.Region + 4, // 3: pluggableharness.frontend.v1.FrontendCapabilities.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_pluggableharness_frontend_v1_types_proto_init() } +func file_pluggableharness_frontend_v1_types_proto_init() { + if File_pluggableharness_frontend_v1_types_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_frontend_v1_types_proto_rawDesc), len(file_pluggableharness_frontend_v1_types_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_frontend_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_frontend_v1_types_proto_depIdxs, + MessageInfos: file_pluggableharness_frontend_v1_types_proto_msgTypes, + }.Build() + File_pluggableharness_frontend_v1_types_proto = out.File + file_pluggableharness_frontend_v1_types_proto_goTypes = nil + file_pluggableharness_frontend_v1_types_proto_depIdxs = nil +} diff --git a/pkg/frontend/server.go b/pkg/frontend/server.go new file mode 100644 index 0000000..568d171 --- /dev/null +++ b/pkg/frontend/server.go @@ -0,0 +1,75 @@ +package frontend + +import ( + "context" + + "google.golang.org/grpc" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" +) + +// Service adapts a Provider into the generated frontendv1.FrontendServiceServer +// and satisfies github.com/pluggableharness/agent/pkg/plugin's Service +// interface, for registration via plugin.Config.Services. Construct one +// with NewService. +type Service struct { + frontendv1.UnimplementedFrontendServiceServer + + provider Provider + identity plugin.Identity + callback *plugin.Callback +} + +var ( + _ plugin.Service = (*Service)(nil) + _ frontendv1.FrontendServiceServer = (*Service)(nil) +) + +// NewService returns a Service wrapping p. identity is this plugin build's +// own self-reported identity — Describe reports it directly, per +// frontend-protocol.md's "Transport" section, rather than the kernel +// inferring it from a lock-file row. callback is this plugin process's +// lazily-dialed handle to the kernel callback channel +// (github.com/pluggableharness/agent/pkg/plugin's Callback); p's own +// methods may dial it via callback.Client to make kernel-callback calls +// (Log, Emit, GetConfig, ...) as part of handling a request. +func NewService(p Provider, identity plugin.Identity, callback *plugin.Callback) *Service { + return &Service{provider: p, identity: identity, callback: callback} +} + +// Register registers the FrontendService on s, satisfying +// github.com/pluggableharness/agent/pkg/plugin's Service interface. +func (svc *Service) Register(s *grpc.Server) { + frontendv1.RegisterFrontendServiceServer(s, svc) +} + +// GetCapabilities returns this frontend's slash commands, config schema, +// supported regions, and supported hook points. Unary. +func (svc *Service) GetCapabilities(ctx context.Context, _ *frontendv1.GetCapabilitiesRequest) (*frontendv1.GetCapabilitiesResponse, error) { + caps, err := svc.provider.Capabilities(ctx) + if err != nil { + return nil, statusErr(err) + } + return &frontendv1.GetCapabilitiesResponse{Capabilities: capabilitiesToProto(caps)}, nil +} + +// Configure applies this provider's agent.hcl configuration. Unary. A +// returned error surfaces as a gRPC status carrying a Error in its +// structured detail, never as an in-band field on ConfigureResponse +// (doc.go's "Error handling is two distinct paths, not one"). +func (svc *Service) Configure(ctx context.Context, req *frontendv1.ConfigureRequest) (*frontendv1.ConfigureResponse, error) { + if err := svc.provider.Configure(ctx, req.GetConfig()); err != nil { + return nil, statusErr(err) + } + return &frontendv1.ConfigureResponse{}, nil +} + +// Describe reports this plugin build's own identity, obtained directly +// from svc.identity rather than a lock-file row — see NewService. +func (svc *Service) Describe(context.Context, *frontendv1.DescribeRequest) (*frontendv1.DescribeResponse, error) { + return &frontendv1.DescribeResponse{ + Producer: svc.identity.ProducerRef(commonv1.Category_CATEGORY_FRONTEND), + }, nil +} diff --git a/pkg/frontend/server_test.go b/pkg/frontend/server_test.go new file mode 100644 index 0000000..a304851 --- /dev/null +++ b/pkg/frontend/server_test.go @@ -0,0 +1,135 @@ +package frontend_test + +import ( + "context" + "errors" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + "github.com/pluggableharness/agent/pkg/frontend" + frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" +) + +func TestService_GetCapabilities(t *testing.T) { + t.Parallel() + + slash := &commonv1.PromptExpansionSpec{Name: "explain"} + provider := &fakeProvider{ + capabilitiesFunc: func(context.Context) (*frontend.Capabilities, error) { + return frontend.NewCapabilities(nil, frontend.WithSlashCommands(slash)), nil + }, + } + client := newTestServer(t, frontend.NewService(provider, testIdentity, nil)) + + resp, err := client.GetCapabilities(t.Context(), &frontendv1.GetCapabilitiesRequest{}) + if err != nil { + t.Fatalf("GetCapabilities() error = %v", err) + } + got := resp.GetCapabilities().GetSlashCommands() + if len(got) != 1 || got[0].GetName() != "explain" { + t.Errorf("GetCapabilities() slash commands = %v, want [explain]", got) + } +} + +func TestService_GetCapabilities_Error(t *testing.T) { + t.Parallel() + + provider := &fakeProvider{ + capabilitiesFunc: func(context.Context) (*frontend.Capabilities, error) { + return nil, &frontend.Error{ + Category: frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNKNOWN, + Message: "boom", + } + }, + } + client := newTestServer(t, frontend.NewService(provider, testIdentity, nil)) + + _, err := client.GetCapabilities(t.Context(), &frontendv1.GetCapabilitiesRequest{}) + if status.Code(err) != codes.Internal { + t.Errorf("GetCapabilities() code = %v, want Internal", status.Code(err)) + } +} + +func TestService_Configure(t *testing.T) { + t.Parallel() + + var gotConfig *structpb.Struct + provider := &fakeProvider{ + configureFunc: func(_ context.Context, config *structpb.Struct) error { + gotConfig = config + return nil + }, + } + client := newTestServer(t, frontend.NewService(provider, testIdentity, nil)) + + cfg, err := structpb.NewStruct(map[string]any{"theme": "dark"}) + if err != nil { + t.Fatalf("structpb.NewStruct() error = %v", err) + } + if _, err := client.Configure(t.Context(), &frontendv1.ConfigureRequest{Config: cfg}); err != nil { + t.Fatalf("Configure() error = %v", err) + } + if gotConfig.GetFields()["theme"].GetStringValue() != "dark" { + t.Errorf("Configure() received config = %v, want theme=dark", gotConfig) + } +} + +func TestService_Configure_InvalidArgument(t *testing.T) { + t.Parallel() + + provider := &fakeProvider{ + configureFunc: func(context.Context, *structpb.Struct) error { + return &frontend.Error{ + Category: frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT, + Message: "malformed theme", + } + }, + } + client := newTestServer(t, frontend.NewService(provider, testIdentity, nil)) + + _, err := client.Configure(t.Context(), &frontendv1.ConfigureRequest{}) + if status.Code(err) != codes.InvalidArgument { + t.Errorf("Configure() code = %v, want InvalidArgument", status.Code(err)) + } +} + +func TestService_Configure_UnmappedError(t *testing.T) { + t.Parallel() + + provider := &fakeProvider{ + configureFunc: func(context.Context, *structpb.Struct) error { + return errors.New("plain error, no category") + }, + } + client := newTestServer(t, frontend.NewService(provider, testIdentity, nil)) + + _, err := client.Configure(t.Context(), &frontendv1.ConfigureRequest{}) + if status.Code(err) != codes.Internal { + t.Errorf("Configure() code = %v, want Internal", status.Code(err)) + } +} + +func TestService_Describe(t *testing.T) { + t.Parallel() + + client := newTestServer(t, frontend.NewService(&fakeProvider{}, testIdentity, nil)) + + resp, err := client.Describe(t.Context(), &frontendv1.DescribeRequest{}) + if err != nil { + t.Fatalf("Describe() error = %v", err) + } + producer := resp.GetProducer() + if producer.GetName() != testIdentity.Name { + t.Errorf("Describe() producer name = %q, want %q", producer.GetName(), testIdentity.Name) + } + if producer.GetVersion() != testIdentity.Version { + t.Errorf("Describe() producer version = %q, want %q", producer.GetVersion(), testIdentity.Version) + } + if producer.GetCategory() != commonv1.Category_CATEGORY_FRONTEND { + t.Errorf("Describe() producer category = %v, want CATEGORY_FRONTEND", producer.GetCategory()) + } +} diff --git a/pkg/hook/convert.go b/pkg/hook/convert.go new file mode 100644 index 0000000..79ebeb0 --- /dev/null +++ b/pkg/hook/convert.go @@ -0,0 +1,98 @@ +package hook + +import ( + "errors" + "fmt" + + "google.golang.org/protobuf/proto" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + hookv1 "github.com/pluggableharness/agent/pkg/hook/proto/v1" +) + +// ErrPayloadVariantUnset is returned when a *hookv1.HookPayload has no +// oneof variant set — a wire-contract violation (hook-dispatch.md's +// "payload MUST be set") this package cannot recover a HookPoint from. +var ErrPayloadVariantUnset = errors.New("hook: payload has no oneof variant set") + +// pointFromPayload derives the commonv1.HookPoint a wire *hookv1.HookPayload +// was dispatched for from which oneof variant it carries — the wire +// contract makes the set variant *the* point +// (agent-loop/hook-dispatch.md#hook-points), so this is the one place that +// table is encoded. +func pointFromPayload(p *hookv1.HookPayload) (commonv1.HookPoint, error) { + switch p.GetPayload().(type) { + case *hookv1.HookPayload_SessionStart: + return commonv1.HookPoint_HOOK_POINT_SESSION_START, nil + case *hookv1.HookPayload_PreModelCall: + return commonv1.HookPoint_HOOK_POINT_PRE_MODEL_CALL, nil + case *hookv1.HookPayload_PostModelResponse: + return commonv1.HookPoint_HOOK_POINT_POST_MODEL_RESPONSE, nil + case *hookv1.HookPayload_PreToolCall: + return commonv1.HookPoint_HOOK_POINT_PRE_TOOL_CALL, nil + case *hookv1.HookPayload_PlanReady: + return commonv1.HookPoint_HOOK_POINT_PLAN_READY, nil + case *hookv1.HookPayload_PostToolCall: + return commonv1.HookPoint_HOOK_POINT_POST_TOOL_CALL, nil + case *hookv1.HookPayload_PostApply: + return commonv1.HookPoint_HOOK_POINT_POST_APPLY, nil + case *hookv1.HookPayload_SessionEnd: + return commonv1.HookPoint_HOOK_POINT_SESSION_END, nil + default: + return commonv1.HookPoint_HOOK_POINT_UNSPECIFIED, fmt.Errorf("hook: point from payload: %w", ErrPayloadVariantUnset) + } +} + +// payloadToDomain wraps a wire *hookv1.HookPayload as an author-facing +// *Payload, deriving Point from the set oneof variant. subscriptionID +// is req.GetSubscriptionId(), passed through unchanged. +func payloadToDomain(p *hookv1.HookPayload, subscriptionID string) (*Payload, error) { + point, err := pointFromPayload(p) + if err != nil { + return nil, err + } + return &Payload{Point: point, SubscriptionID: subscriptionID, proto: p}, nil +} + +// cloneHookPayload returns a deep copy of p, or nil if p is nil. +func cloneHookPayload(p *hookv1.HookPayload) *hookv1.HookPayload { + if p == nil { + return nil + } + cloned, ok := proto.Clone(p).(*hookv1.HookPayload) + if !ok { + // proto.Clone always returns a value of the same concrete type + // it was given; this branch is unreachable for a well-formed + // *hookv1.HookPayload and exists only so the type assertion is + // checked rather than blind per go-style.md's comma-ok rule. + return nil + } + return cloned +} + +// clearPreModelCallMessages zeroes PreModelCallPayload.messages on p, the +// one field agent-loop/hook-dispatch.md's per-point mutable-field table +// documents as transform-mutable in v1. A no-op for any other variant. +func clearPreModelCallMessages(p *hookv1.HookPayload) { + if pm := p.GetPreModelCall(); pm != nil { + pm.Messages = nil + } +} + +// payloadsEqualExceptMutable reports whether resp is identical to req once +// point's transform-mutable fields +// (agent-loop/hook-dispatch.md#per-point-transform-mutable-fields) are +// cleared from both sides first. Only pre-model-call's messages field is +// mutable in v1 — every other point takes the default branch, clearing +// nothing, which makes the check equivalent to a plain proto.Equal and so +// also catches a transform response returning the wrong oneof variant +// entirely (proto.Equal treats a differing oneof case as unequal). +func payloadsEqualExceptMutable(point commonv1.HookPoint, req, resp *hookv1.HookPayload) bool { + reqClone := cloneHookPayload(req) + respClone := cloneHookPayload(resp) + if point == commonv1.HookPoint_HOOK_POINT_PRE_MODEL_CALL { + clearPreModelCallMessages(reqClone) + clearPreModelCallMessages(respClone) + } + return proto.Equal(reqClone, respClone) +} diff --git a/pkg/hook/convert_internal_test.go b/pkg/hook/convert_internal_test.go new file mode 100644 index 0000000..b130503 --- /dev/null +++ b/pkg/hook/convert_internal_test.go @@ -0,0 +1,240 @@ +package hook + +import ( + "errors" + "testing" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + hookv1 "github.com/pluggableharness/agent/pkg/hook/proto/v1" + modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" + planv1 "github.com/pluggableharness/agent/pkg/plan/proto/v1" + sessionv1 "github.com/pluggableharness/agent/pkg/session/proto/v1" + toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" +) + +func sessionStartPayload() *hookv1.HookPayload { + return &hookv1.HookPayload{Payload: &hookv1.HookPayload_SessionStart{ + SessionStart: &hookv1.SessionStartPayload{SessionId: "session-1", Profile: "default", WorkingDirectory: "/work"}, + }} +} + +func preModelCallPayload(messages ...*contentv1.Message) *hookv1.HookPayload { + return &hookv1.HookPayload{Payload: &hookv1.HookPayload_PreModelCall{ + PreModelCall: &hookv1.PreModelCallPayload{ + Messages: messages, + Model: &modelv1.ModelRef{Provider: "anthropic", Id: "claude"}, + }, + }} +} + +func postModelResponsePayload() *hookv1.HookPayload { + return &hookv1.HookPayload{Payload: &hookv1.HookPayload_PostModelResponse{ + PostModelResponse: &hookv1.PostModelResponsePayload{ + Message: &contentv1.Message{Role: contentv1.Role_ROLE_ASSISTANT}, + }, + }} +} + +func preToolCallPayload() *hookv1.HookPayload { + return &hookv1.HookPayload{Payload: &hookv1.HookPayload_PreToolCall{ + PreToolCall: &hookv1.PreToolCallPayload{ + Call: &toolv1.ToolCall{Id: "call-1", ToolName: "grep"}, + PlanItem: &planv1.PlanItem{Id: "item-1"}, + }, + }} +} + +func planReadyPayload() *hookv1.HookPayload { + return &hookv1.HookPayload{Payload: &hookv1.HookPayload_PlanReady{ + PlanReady: &hookv1.PlanReadyPayload{Plan: &planv1.Plan{TurnId: "turn-1"}}, + }} +} + +func postToolCallPayload() *hookv1.HookPayload { + return &hookv1.HookPayload{Payload: &hookv1.HookPayload_PostToolCall{ + PostToolCall: &hookv1.PostToolCallPayload{ + Call: &toolv1.ToolCall{Id: "call-1", ToolName: "grep"}, + Outcome: &hookv1.PostToolCallPayload_Result{Result: &toolv1.ToolResult{}}, + }, + }} +} + +func postApplyPayload() *hookv1.HookPayload { + return &hookv1.HookPayload{Payload: &hookv1.HookPayload_PostApply{ + PostApply: &hookv1.PostApplyPayload{Apply: &planv1.ApplyResult{TurnId: "turn-1"}}, + }} +} + +func sessionEndPayload() *hookv1.HookPayload { + return &hookv1.HookPayload{Payload: &hookv1.HookPayload_SessionEnd{ + SessionEnd: &hookv1.SessionEndPayload{SessionId: "session-1", Status: sessionv1.SessionStatus_SESSION_STATUS_COMPLETED}, + }} +} + +func TestPointFromPayload(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + payload *hookv1.HookPayload + want commonv1.HookPoint + }{ + {"session-start", sessionStartPayload(), commonv1.HookPoint_HOOK_POINT_SESSION_START}, + {"pre-model-call", preModelCallPayload(), commonv1.HookPoint_HOOK_POINT_PRE_MODEL_CALL}, + {"post-model-response", postModelResponsePayload(), commonv1.HookPoint_HOOK_POINT_POST_MODEL_RESPONSE}, + {"pre-tool-call", preToolCallPayload(), commonv1.HookPoint_HOOK_POINT_PRE_TOOL_CALL}, + {"plan-ready", planReadyPayload(), commonv1.HookPoint_HOOK_POINT_PLAN_READY}, + {"post-tool-call", postToolCallPayload(), commonv1.HookPoint_HOOK_POINT_POST_TOOL_CALL}, + {"post-apply", postApplyPayload(), commonv1.HookPoint_HOOK_POINT_POST_APPLY}, + {"session-end", sessionEndPayload(), commonv1.HookPoint_HOOK_POINT_SESSION_END}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := pointFromPayload(tt.payload) + if err != nil { + t.Fatalf("pointFromPayload(%s) unexpected error: %v", tt.name, err) + } + if got != tt.want { + t.Errorf("pointFromPayload(%s) = %v, want %v", tt.name, got, tt.want) + } + }) + } +} + +func TestPointFromPayload_UnsetVariant(t *testing.T) { + t.Parallel() + + _, err := pointFromPayload(&hookv1.HookPayload{}) + if !errors.Is(err, ErrPayloadVariantUnset) { + t.Errorf("pointFromPayload(empty) error = %v, want wrapping ErrPayloadVariantUnset", err) + } +} + +func TestPayloadToDomain(t *testing.T) { + t.Parallel() + + proto := sessionStartPayload() + domain, err := payloadToDomain(proto, "sub-1") + if err != nil { + t.Fatalf("payloadToDomain() unexpected error: %v", err) + } + if domain.Point != commonv1.HookPoint_HOOK_POINT_SESSION_START { + t.Errorf("payloadToDomain().Point = %v, want HOOK_POINT_SESSION_START", domain.Point) + } + if domain.SubscriptionID != "sub-1" { + t.Errorf("payloadToDomain().SubscriptionID = %q, want %q", domain.SubscriptionID, "sub-1") + } + if domain.Proto() != proto { + t.Error("payloadToDomain().Proto() did not return the same wire message it wrapped") + } +} + +func TestPayloadToDomain_InvalidVariant(t *testing.T) { + t.Parallel() + + if _, err := payloadToDomain(&hookv1.HookPayload{}, ""); err == nil { + t.Error("payloadToDomain(empty) = nil error, want ErrPayloadVariantUnset") + } +} + +func TestCloneHookPayload(t *testing.T) { + t.Parallel() + + if got := cloneHookPayload(nil); got != nil { + t.Errorf("cloneHookPayload(nil) = %v, want nil", got) + } + + original := preModelCallPayload(&contentv1.Message{Role: contentv1.Role_ROLE_USER}) + cloned := cloneHookPayload(original) + if cloned == original { + t.Error("cloneHookPayload returned the same pointer, want a deep copy") + } + if cloned.GetPreModelCall().GetModel().GetProvider() != "anthropic" { + t.Errorf("cloneHookPayload().GetPreModelCall().GetModel().GetProvider() = %q, want %q", + cloned.GetPreModelCall().GetModel().GetProvider(), "anthropic") + } + + // Mutating the clone MUST NOT affect the original. + cloned.GetPreModelCall().Model.Provider = "mutated" + if original.GetPreModelCall().GetModel().GetProvider() != "anthropic" { + t.Error("mutating the clone changed the original — clone is not deep") + } +} + +func TestClearPreModelCallMessages(t *testing.T) { + t.Parallel() + + // Nil-safe for any other variant. + other := sessionStartPayload() + clearPreModelCallMessages(other) + + p := preModelCallPayload(&contentv1.Message{Role: contentv1.Role_ROLE_USER}) + clearPreModelCallMessages(p) + if len(p.GetPreModelCall().GetMessages()) != 0 { + t.Errorf("clearPreModelCallMessages left %d messages, want 0", len(p.GetPreModelCall().GetMessages())) + } +} + +func TestPayloadsEqualExceptMutable(t *testing.T) { + t.Parallel() + + msg := &contentv1.Message{Role: contentv1.Role_ROLE_USER} + + tests := []struct { + name string + point commonv1.HookPoint + req *hookv1.HookPayload + resp *hookv1.HookPayload + want bool + }{ + { + name: "identical payloads at a non-mutable point", + point: commonv1.HookPoint_HOOK_POINT_SESSION_START, + req: sessionStartPayload(), + resp: sessionStartPayload(), + want: true, + }, + { + name: "pre-model-call messages changed is allowed", + point: commonv1.HookPoint_HOOK_POINT_PRE_MODEL_CALL, + req: preModelCallPayload(msg), + resp: preModelCallPayload(), + want: true, + }, + { + name: "pre-model-call model field changed is rejected", + point: commonv1.HookPoint_HOOK_POINT_PRE_MODEL_CALL, + req: preModelCallPayload(msg), + resp: &hookv1.HookPayload{Payload: &hookv1.HookPayload_PreModelCall{ + PreModelCall: &hookv1.PreModelCallPayload{Model: &modelv1.ModelRef{Provider: "different", Id: "claude"}}, + }}, + want: false, + }, + { + name: "non-mutable point with any field changed is rejected", + point: commonv1.HookPoint_HOOK_POINT_SESSION_START, + req: sessionStartPayload(), + resp: &hookv1.HookPayload{Payload: &hookv1.HookPayload_SessionStart{ + SessionStart: &hookv1.SessionStartPayload{SessionId: "different", Profile: "default", WorkingDirectory: "/work"}, + }}, + want: false, + }, + { + name: "wrong oneof variant is rejected", + point: commonv1.HookPoint_HOOK_POINT_SESSION_START, + req: sessionStartPayload(), + resp: sessionEndPayload(), + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := payloadsEqualExceptMutable(tt.point, tt.req, tt.resp); got != tt.want { + t.Errorf("payloadsEqualExceptMutable(%s) = %v, want %v", tt.name, got, tt.want) + } + }) + } +} diff --git a/pkg/hook/doc.go b/pkg/hook/doc.go new file mode 100644 index 0000000..6581e5b --- /dev/null +++ b/pkg/hook/doc.go @@ -0,0 +1,53 @@ +// Package hook is the plugin-author-facing SDK for +// pluggableharness.hook.v1.HookSubscriberService, the wire contract +// described in full in docs/specifications/agent-loop/hook-dispatch.md +// (mechanics) and docs/specifications/architecture.md's "Hook dispatch +// semantics" section (the surrounding observe/transform/veto model). +// +// # A shared service, not a category SDK +// +// Unlike pkg/model, pkg/tool, pkg/context, pkg/memory, pkg/frontend, and +// pkg/widget, this package is not tied to one of the seven plugin +// categories. HookSubscriberService is a service any category plugin MAY +// additionally implement alongside its primary category service, muxed on +// the same hashicorp/go-plugin subprocess connection via +// pkg/plugin.Config.Services (hook-dispatch.md's "one shared service, not +// a per-category RPC"). A tool plugin that also wants to observe +// post-model-response, or a model plugin that wants to veto plan-ready, +// builds a Subscriber (or one of its narrower Observer/Transformer/Vetoer +// facets) and passes NewService's result alongside its category Service in +// the same Config.Services slice — this package has no dependency on any +// of the six category SDKs and no category SDK needs to depend on it to be +// usable standalone. +// +// # Eight of nine hook points +// +// common.v1.HookPoint enumerates eight of architecture.md's nine named +// hook points; context-assemble is deliberately absent — it stays on +// ContextService.Contribute (docs/specifications/context/protocol.md#contribute-the-context-assemble-rpc), +// which already carries the full accumulated ContextSection chain, rather +// than riding this generic surface a second time. hook.go documents the +// eight points this package does serve. +// +// # Dispatch modes and the split Subscriber interfaces +// +// DispatchHook is unary: one hook-point firing, delivered to one +// subscriber, is one request/one response +// (hook-dispatch.md#dispatch-order-and-payload-flow). The kernel-side +// dispatch loop that walks the ordered chain of subscribers for a given +// hook point, and that decides what an observe-mode failure or a +// veto-mode timeout means for the *rest* of that chain, lives in the +// kernel, not here — this package's Service adapts exactly one +// subscriber's exactly one RPC invocation. See server.go's DispatchHook +// for what that boundary means concretely for observe-mode error +// handling. +// +// A plugin author implements one or more of Observer, Transformer, and +// Vetoer (hook.go) — not a single monolithic Subscriber interface — +// because a real subscription is declared per (hook point, mode) pair in +// agent.hcl, and most plugins subscribe to only one or two combinations. +// NewService detects which of the three a given value implements via type +// assertion, the same optional-facet pattern pkg/render's Render/Preview +// split uses, so a pure audit logger never has to write no-op Transform +// and Veto stubs it will never be called for. +package hook diff --git a/pkg/hook/errors.go b/pkg/hook/errors.go new file mode 100644 index 0000000..f4468e7 --- /dev/null +++ b/pkg/hook/errors.go @@ -0,0 +1,132 @@ +package hook + +import ( + "context" + "errors" + "fmt" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + hookv1 "github.com/pluggableharness/agent/pkg/hook/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" +) + +// errorDomain is this package's google.rpc.ErrorInfo domain, per +// .claude/rules/grpc.md's "most specific code, category enum in +// structured detail" convention. +const errorDomain = "hook.pluggableharness.dev" + +// ErrSubscriberNotImplemented is wrapped into the gRPC status +// errNotImplemented returns when a DispatchHook request's mode names a +// facet (Observer, Transformer, Vetoer) the constructed Service's +// subscriber value does not implement — an agent.hcl/plugin mismatch, not +// a malformed subscriber response. +var ErrSubscriberNotImplemented = errors.New("hook: subscriber does not implement the requested mode") + +// errorMetadata builds the google.rpc.ErrorInfo metadata this package +// attaches to every structured error, so a kernel inspecting the status +// detail can reconstruct enough of a HookError +// (agent-loop/hook-dispatch.md; errors.pb.go's HookError doc comment) +// without re-deriving it purely from the gRPC code. +func errorMetadata(point commonv1.HookPoint, mode hookv1.HookMode, category hookv1.HookErrorCategory) map[string]string { + return map[string]string{ + "hook_point": point.String(), + "mode": mode.String(), + "category": category.String(), + } +} + +// errInvalidResponse builds the HOOK_ERROR_CATEGORY_INVALID_RESPONSE +// error for every shape-mismatch case +// agent-loop/hook-dispatch.md#invalid_response-handling defines: a +// transform response of the wrong oneof variant, a transform response +// mutating a field the mutable-field table doesn't list, or +// HOOK_DECISION_UNSPECIFIED on a veto response. codes.InvalidArgument +// because the failure is the subscriber's own response, not a transport +// or downstream condition — per rpc_response.pb.go's VetoResult doc +// comment, the fail-closed conversion for a veto response this invalid is +// the kernel's job once it sees this status, not this package fabricating +// an in-band VetoResult{DENY} here. +func errInvalidResponse(point commonv1.HookPoint, mode hookv1.HookMode, reason, message string) error { + return plugin.StatusError(codes.InvalidArgument, errorDomain, reason, message, + errorMetadata(point, mode, hookv1.HookErrorCategory_HOOK_ERROR_CATEGORY_INVALID_RESPONSE)) +} + +// errTransformFailed builds the HOOK_ERROR_CATEGORY_TRANSFORM_FAILED error +// for a Transformer that returned a genuine error (as opposed to an +// invalid response shape, which is errInvalidResponse's job). +// codes.Internal per .claude/rules/grpc.md's "Internal is the safe +// unmapped default, never Unknown" for a subscriber-side failure with no +// more specific taxonomy entry. +func errTransformFailed(point commonv1.HookPoint, cause error) error { + return plugin.StatusError(codes.Internal, errorDomain, "transform_failed", fmt.Sprintf("hook: transform: %v", cause), + errorMetadata(point, hookv1.HookMode_HOOK_MODE_TRANSFORM, hookv1.HookErrorCategory_HOOK_ERROR_CATEGORY_TRANSFORM_FAILED)) +} + +// errVetoFailed builds the HOOK_ERROR_CATEGORY_VETO_FAILED error for a +// Vetoer that returned a genuine error. The kernel treats this identically +// to an explicit deny — fail-closed +// (agent-loop/hook-dispatch.md#subscriber-error-handling) — by virtue of +// this being a non-nil gRPC error at all, not because this package encodes +// DecisionDeny anywhere in it. +func errVetoFailed(point commonv1.HookPoint, cause error) error { + return plugin.StatusError(codes.Internal, errorDomain, "veto_failed", fmt.Sprintf("hook: veto: %v", cause), + errorMetadata(point, hookv1.HookMode_HOOK_MODE_VETO, hookv1.HookErrorCategory_HOOK_ERROR_CATEGORY_VETO_FAILED)) +} + +// errObserveFailed builds the error for an Observer that returned a +// genuine error. HookErrorCategory has no dedicated +// HOOK_ERROR_CATEGORY_OBSERVE_FAILED entry (only transform and veto get +// named categories — errors.pb.go), so this uses +// HOOK_ERROR_CATEGORY_UNKNOWN; codes.Internal for the same +// safe-unmapped-default reason as errTransformFailed/errVetoFailed. This +// error still reaches the kernel as a normal RPC failure — see +// server.go's DispatchHook doc comment for why this layer reports rather +// than swallows an Observer error. +func errObserveFailed(point commonv1.HookPoint, cause error) error { + return plugin.StatusError(codes.Internal, errorDomain, "observe_failed", fmt.Sprintf("hook: observe: %v", cause), + errorMetadata(point, hookv1.HookMode_HOOK_MODE_OBSERVE, hookv1.HookErrorCategory_HOOK_ERROR_CATEGORY_UNKNOWN)) +} + +// errNotImplemented builds the error DispatchHook returns when the +// constructed Service's subscriber doesn't implement the facet mode +// requires — wrapping ErrSubscriberNotImplemented. codes.Unimplemented: +// this is a plugin/agent.hcl configuration mismatch (a hook{} block +// declared a mode this plugin's Go value never implements), not a +// malformed response or a runtime subscriber failure. For a veto-mode +// request this still fails closed at the kernel exactly like any other +// error response would, per hook-dispatch.md#timeout-behavior. +func errNotImplemented(point commonv1.HookPoint, mode hookv1.HookMode) error { + return plugin.StatusError(codes.Unimplemented, errorDomain, "mode_not_implemented", + fmt.Sprintf("%s: %s", ErrSubscriberNotImplemented, mode), errorMetadata(point, mode, hookv1.HookErrorCategory_HOOK_ERROR_CATEGORY_UNKNOWN)) +} + +// errInvalidRequest builds the error DispatchHook returns when the +// request itself violates the wire contract (hook-dispatch.md's "payload +// MUST be set", "mode MUST be set") — a kernel-side bug, not a subscriber +// failure, so it carries no HookErrorCategory (none of the seven fit a +// malformed *request*). codes.InvalidArgument regardless. +func errInvalidRequest(reason, message string) error { + return plugin.StatusError(codes.InvalidArgument, errorDomain, reason, message, nil) +} + +// mapContextErr translates a context error into the matching gRPC status +// (codes.Canceled / codes.DeadlineExceeded), or returns nil if err is nil +// or not a context error. Used both on ctx.Err() directly and on an +// author-returned error that may simply be the same ctx error bubbled back +// up — .claude/rules/grpc.md: "Cancellation is normal control flow, not an +// error"; never logged or reported as a subscriber failure. +func mapContextErr(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, context.Canceled): + return status.Error(codes.Canceled, err.Error()) + case errors.Is(err, context.DeadlineExceeded): + return status.Error(codes.DeadlineExceeded, err.Error()) + default: + return nil + } +} diff --git a/pkg/hook/errors_internal_test.go b/pkg/hook/errors_internal_test.go new file mode 100644 index 0000000..624a717 --- /dev/null +++ b/pkg/hook/errors_internal_test.go @@ -0,0 +1,158 @@ +package hook + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + hookv1 "github.com/pluggableharness/agent/pkg/hook/proto/v1" +) + +// errorInfo extracts the google.rpc.ErrorInfo detail plugin.StatusError +// attaches, failing the test if err carries none. +func errorInfo(t *testing.T, err error) *errdetails.ErrorInfo { + t.Helper() + st, ok := status.FromError(err) + if !ok { + t.Fatalf("status.FromError(%v) ok = false, want a *status.Status-backed error", err) + } + for _, d := range st.Details() { + if info, ok := d.(*errdetails.ErrorInfo); ok { + return info + } + } + t.Fatalf("error %v carries no google.rpc.ErrorInfo detail", err) + return nil +} + +func TestErrorMetadata(t *testing.T) { + t.Parallel() + + got := errorMetadata(commonv1.HookPoint_HOOK_POINT_PLAN_READY, hookv1.HookMode_HOOK_MODE_VETO, hookv1.HookErrorCategory_HOOK_ERROR_CATEGORY_VETO_FAILED) + want := map[string]string{ + "hook_point": "HOOK_POINT_PLAN_READY", + "mode": "HOOK_MODE_VETO", + "category": "HOOK_ERROR_CATEGORY_VETO_FAILED", + } + for k, v := range want { + if got[k] != v { + t.Errorf("errorMetadata()[%q] = %q, want %q", k, got[k], v) + } + } +} + +func TestErrInvalidResponse(t *testing.T) { + t.Parallel() + + err := errInvalidResponse(commonv1.HookPoint_HOOK_POINT_PLAN_READY, hookv1.HookMode_HOOK_MODE_VETO, "veto_decision_unspecified", "hook: veto: response decision is unspecified") + st, ok := status.FromError(err) + if !ok || st.Code() != codes.InvalidArgument { + t.Errorf("errInvalidResponse() code = %v (ok=%v), want codes.InvalidArgument", st.Code(), ok) + } + info := errorInfo(t, err) + if info.GetReason() != "veto_decision_unspecified" { + t.Errorf("errInvalidResponse() reason = %q, want %q", info.GetReason(), "veto_decision_unspecified") + } + if info.GetDomain() != errorDomain { + t.Errorf("errInvalidResponse() domain = %q, want %q", info.GetDomain(), errorDomain) + } + if info.GetMetadata()["category"] != "HOOK_ERROR_CATEGORY_INVALID_RESPONSE" { + t.Errorf("errInvalidResponse() category = %q, want HOOK_ERROR_CATEGORY_INVALID_RESPONSE", info.GetMetadata()["category"]) + } +} + +func TestErrTransformFailed(t *testing.T) { + t.Parallel() + + cause := errors.New("boom") + err := errTransformFailed(commonv1.HookPoint_HOOK_POINT_PRE_MODEL_CALL, cause) + st, ok := status.FromError(err) + if !ok || st.Code() != codes.Internal { + t.Errorf("errTransformFailed() code = %v (ok=%v), want codes.Internal", st.Code(), ok) + } + if info := errorInfo(t, err); info.GetMetadata()["category"] != "HOOK_ERROR_CATEGORY_TRANSFORM_FAILED" { + t.Errorf("errTransformFailed() category = %q, want HOOK_ERROR_CATEGORY_TRANSFORM_FAILED", info.GetMetadata()["category"]) + } +} + +func TestErrVetoFailed(t *testing.T) { + t.Parallel() + + err := errVetoFailed(commonv1.HookPoint_HOOK_POINT_PLAN_READY, errors.New("boom")) + st, ok := status.FromError(err) + if !ok || st.Code() != codes.Internal { + t.Errorf("errVetoFailed() code = %v (ok=%v), want codes.Internal", st.Code(), ok) + } + if info := errorInfo(t, err); info.GetMetadata()["category"] != "HOOK_ERROR_CATEGORY_VETO_FAILED" { + t.Errorf("errVetoFailed() category = %q, want HOOK_ERROR_CATEGORY_VETO_FAILED", info.GetMetadata()["category"]) + } +} + +func TestErrObserveFailed(t *testing.T) { + t.Parallel() + + err := errObserveFailed(commonv1.HookPoint_HOOK_POINT_SESSION_START, errors.New("boom")) + st, ok := status.FromError(err) + if !ok || st.Code() != codes.Internal { + t.Errorf("errObserveFailed() code = %v (ok=%v), want codes.Internal", st.Code(), ok) + } + if info := errorInfo(t, err); info.GetMetadata()["category"] != "HOOK_ERROR_CATEGORY_UNKNOWN" { + t.Errorf("errObserveFailed() category = %q, want HOOK_ERROR_CATEGORY_UNKNOWN", info.GetMetadata()["category"]) + } +} + +func TestErrNotImplemented(t *testing.T) { + t.Parallel() + + err := errNotImplemented(commonv1.HookPoint_HOOK_POINT_SESSION_END, hookv1.HookMode_HOOK_MODE_OBSERVE) + st, ok := status.FromError(err) + if !ok || st.Code() != codes.Unimplemented { + t.Errorf("errNotImplemented() code = %v (ok=%v), want codes.Unimplemented", st.Code(), ok) + } + if !strings.Contains(st.Message(), ErrSubscriberNotImplemented.Error()) { + t.Errorf("errNotImplemented() message = %q, want it to contain %q", st.Message(), ErrSubscriberNotImplemented.Error()) + } +} + +func TestErrInvalidRequest(t *testing.T) { + t.Parallel() + + err := errInvalidRequest("mode_unset", "hook: dispatch: request mode is unspecified") + st, ok := status.FromError(err) + if !ok || st.Code() != codes.InvalidArgument { + t.Errorf("errInvalidRequest() code = %v (ok=%v), want codes.InvalidArgument", st.Code(), ok) + } + if info := errorInfo(t, err); info.GetReason() != "mode_unset" { + t.Errorf("errInvalidRequest() reason = %q, want %q", info.GetReason(), "mode_unset") + } +} + +func TestMapContextErr(t *testing.T) { + t.Parallel() + + if got := mapContextErr(nil); got != nil { + t.Errorf("mapContextErr(nil) = %v, want nil", got) + } + if got := mapContextErr(errors.New("boom")); got != nil { + t.Errorf("mapContextErr(non-context error) = %v, want nil", got) + } + + if got := mapContextErr(context.Canceled); status.Code(got) != codes.Canceled { + t.Errorf("mapContextErr(context.Canceled) code = %v, want codes.Canceled", status.Code(got)) + } + if got := mapContextErr(context.DeadlineExceeded); status.Code(got) != codes.DeadlineExceeded { + t.Errorf("mapContextErr(context.DeadlineExceeded) code = %v, want codes.DeadlineExceeded", status.Code(got)) + } + + wrapped := fmt.Errorf("hook: dispatch: %w", context.Canceled) + if got := mapContextErr(wrapped); status.Code(got) != codes.Canceled { + t.Errorf("mapContextErr(wrapped context.Canceled) code = %v, want codes.Canceled", status.Code(got)) + } +} diff --git a/pkg/hook/hook.go b/pkg/hook/hook.go new file mode 100644 index 0000000..2ee399d --- /dev/null +++ b/pkg/hook/hook.go @@ -0,0 +1,150 @@ +package hook + +import ( + "context" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + hookv1 "github.com/pluggableharness/agent/pkg/hook/proto/v1" +) + +// Mode is a subscription's operator-declared dispatch mode +// (agent.hcl's hook{} block), echoed on every DispatchHook request so a +// subscriber knows which of the three outcome shapes is expected back. +// Mode is a direct alias of the generated enum — per go-layout.md's +// "exactly one Go representation of each wire message", this package does +// not define a parallel enum type, only the idiomatic, non-stuttering +// constant names below. +type Mode = hookv1.HookMode + +// The three dispatch modes a Subscriber facet may be invoked under, per +// agent-loop/hook-dispatch.md#dispatch-modes--response-shapes. +const ( + // ModeObserve is read-only and fire-and-forget: an Observer's error + // is logged by the kernel and never aborts the chain. + ModeObserve = hookv1.HookMode_HOOK_MODE_OBSERVE + // ModeTransform is a sequential chain member: a Transformer receives + // the prior stage's payload and returns a modified version of the + // same variant. + ModeTransform = hookv1.HookMode_HOOK_MODE_TRANSFORM + // ModeVeto expects an explicit allow/deny verdict from a Vetoer. + ModeVeto = hookv1.HookMode_HOOK_MODE_VETO +) + +// Decision is a Vetoer's allow/deny verdict over a whole Payload — a +// direct alias of the generated enum, per the same "one wire +// representation" rule Mode follows above. +type Decision = hookv1.HookDecision + +// The two valid Decision values a Vetoer may return. +// DecisionUnspecified is exported only so tests and validation code have +// a name for the invalid zero value; a Vetoer implementation MUST NOT +// return it (server.go rejects it as HOOK_ERROR_CATEGORY_INVALID_RESPONSE +// before it reaches the wire). +const ( + DecisionUnspecified = hookv1.HookDecision_HOOK_DECISION_UNSPECIFIED + DecisionAllow = hookv1.HookDecision_HOOK_DECISION_ALLOW + DecisionDeny = hookv1.HookDecision_HOOK_DECISION_DENY +) + +// Points is the ordered set of the eight hook points this package's +// HookSubscriberService surface dispatches +// (agent-loop/hook-dispatch.md#hook-points). context-assemble — +// architecture.md's ninth named point — is deliberately absent; it stays +// on ContextService.Contribute (doc.go). +var Points = []commonv1.HookPoint{ + commonv1.HookPoint_HOOK_POINT_SESSION_START, + commonv1.HookPoint_HOOK_POINT_PRE_MODEL_CALL, + commonv1.HookPoint_HOOK_POINT_POST_MODEL_RESPONSE, + commonv1.HookPoint_HOOK_POINT_PRE_TOOL_CALL, + commonv1.HookPoint_HOOK_POINT_PLAN_READY, + commonv1.HookPoint_HOOK_POINT_POST_TOOL_CALL, + commonv1.HookPoint_HOOK_POINT_POST_APPLY, + commonv1.HookPoint_HOOK_POINT_SESSION_END, +} + +// Payload is the author-facing wrapper for one hook-point firing's +// payload. Point is derived from which oneof variant the wire message +// carries — the wire contract makes the set variant *the* hook point +// (hook-dispatch.md#hook-points), so a Subscriber facet does not have to +// re-discover it via its own type switch on every call. SubscriptionID +// disambiguates a plugin declaring more than one hook{} block at the same +// HookPoint (empty when the plugin has exactly one subscription there). +// +// This type does not duplicate the eight per-point payload messages +// (SessionStartPayload, PreModelCallPayload, ...) as a second, parallel Go +// representation — go-layout.md's "exactly one Go representation of each +// wire message" rule. Use Proto to reach the generated oneof carrier and +// its own GetXxx accessors (e.g. payload.Proto().GetPreModelCall()). +type Payload struct { + // Point is the hook point this payload was dispatched for. + Point commonv1.HookPoint + // SubscriptionID disambiguates multiple hook{} blocks at the same + // Point declared by the same plugin. Empty when there is exactly one. + SubscriptionID string + + proto *hookv1.HookPayload +} + +// Proto returns the underlying generated *hookv1.HookPayload oneof +// carrier this Payload wraps. +func (p *Payload) Proto() *hookv1.HookPayload { + if p == nil { + return nil + } + return p.proto +} + +// NewPayload wraps proto as a Payload, deriving Point from +// whichever oneof variant proto carries. Returns ErrPayloadVariantUnset if +// none is set. +// +// A Transformer has two equally valid ways to produce its return value: +// mutate the payload it was handed in place via its Proto accessor (e.g. +// `payload.Proto().GetPreModelCall().Messages = redacted`) and return the +// same *Payload, or build a fresh *hookv1.HookPayload and wrap it with +// NewPayload before returning it. server.go's DispatchHook snapshots +// the request payload before invoking Transform specifically so both +// styles are validated against the same pristine baseline — an in-place +// mutation is never compared against itself. +func NewPayload(proto *hookv1.HookPayload) (*Payload, error) { + return payloadToDomain(proto, "") +} + +// Observer handles a HOOK_MODE_OBSERVE dispatch. Observe is read-only and +// fire-and-forget: per agent-loop/hook-dispatch.md#subscriber-error-handling, +// an Observer error is logged by the kernel (as an event on the state +// backend, producer = this subscriber) and the kernel's dispatch chain +// continues regardless — the failure is never fatal to that chain. See +// server.go's DispatchHook for exactly what this SDK layer does with an +// Observe error, since the chain itself is kernel-side, not here. +type Observer interface { + Observe(ctx context.Context, payload *Payload) error +} + +// Transformer handles a HOOK_MODE_TRANSFORM dispatch. It MUST return a +// payload of the same Payload oneof variant it received, and MUST NOT +// change any field this hook point's mutable-field table +// (agent-loop/hook-dispatch.md#per-point-transform-mutable-fields) doesn't +// list as transform-mutable — in v1 that is exactly one field, +// pre-model-call's messages. server.go validates both constraints before +// a Transform response ever reaches the wire; a violation there is +// reported as HOOK_ERROR_CATEGORY_INVALID_RESPONSE, never silently +// forwarded. +type Transformer interface { + Transform(ctx context.Context, payload *Payload) (*Payload, error) +} + +// Vetoer handles a HOOK_MODE_VETO dispatch. It MUST return DecisionAllow +// or DecisionDeny — never DecisionUnspecified, which server.go rejects at +// the SDK layer before it ever reaches the wire (as +// HOOK_ERROR_CATEGORY_INVALID_RESPONSE) rather than letting an +// under-specified verdict through. A returned error is treated identically +// to an explicit deny by the kernel — fail-closed +// (agent-loop/hook-dispatch.md#timeout-behavior) — but this SDK layer does +// not itself substitute DecisionDeny for an error; see errors.go and +// Service.DispatchHook's doc comment (server.go) for why the fail-closed +// conversion happens at the gRPC-status level, in the kernel, not by this +// layer fabricating an in-band VetoResult. +type Vetoer interface { + Veto(ctx context.Context, payload *Payload) (Decision, error) +} diff --git a/pkg/hook/hook_test.go b/pkg/hook/hook_test.go new file mode 100644 index 0000000..5cc42cf --- /dev/null +++ b/pkg/hook/hook_test.go @@ -0,0 +1,44 @@ +package hook_test + +import ( + "testing" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + "github.com/pluggableharness/agent/pkg/hook" + hookv1 "github.com/pluggableharness/agent/pkg/hook/proto/v1" +) + +func TestHookPayload_ProtoNilReceiver(t *testing.T) { + t.Parallel() + + var nilPayload *hook.Payload + if got := nilPayload.Proto(); got != nil { + t.Errorf("(*Payload)(nil).Proto() = %v, want nil", got) + } +} + +func TestNewHookPayload(t *testing.T) { + t.Parallel() + + proto := &hookv1.HookPayload{Payload: &hookv1.HookPayload_SessionEnd{ + SessionEnd: &hookv1.SessionEndPayload{SessionId: "session-1"}, + }} + payload, err := hook.NewPayload(proto) + if err != nil { + t.Fatalf("NewPayload() unexpected error: %v", err) + } + if payload.Point != commonv1.HookPoint_HOOK_POINT_SESSION_END { + t.Errorf("NewPayload().Point = %v, want HOOK_POINT_SESSION_END", payload.Point) + } + if payload.Proto() != proto { + t.Error("NewPayload().Proto() did not return the wrapped message") + } +} + +func TestNewHookPayload_InvalidVariant(t *testing.T) { + t.Parallel() + + if _, err := hook.NewPayload(&hookv1.HookPayload{}); err == nil { + t.Error("NewPayload(empty) = nil error, want ErrPayloadVariantUnset") + } +} diff --git a/pkg/hook/proto/v1/errors.pb.go b/pkg/hook/proto/v1/errors.pb.go new file mode 100644 index 0000000..fd5c664 --- /dev/null +++ b/pkg/hook/proto/v1/errors.pb.go @@ -0,0 +1,279 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/hook/v1/errors.proto + +package hookv1 + +import ( + v1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// HookErrorCategory classifies why a hook dispatch to one subscriber +// failed. Deliberately its own enum, not reused from tool.v1 or model.v1 — +// per .claude/rules/proto.md, a category proto's error taxonomy is never +// merged with another category's even where the surrounding shape is +// parallel. +type HookErrorCategory int32 + +const ( + // Zero value. Never valid for a real error; its presence on the wire + // means a caller forgot to set the field. + HookErrorCategory_HOOK_ERROR_CATEGORY_UNSPECIFIED HookErrorCategory = 0 + // A transform subscriber raised an error or returned a malformed + // response. Aborts the remainder of that hook's chain + // (agent-loop/hook-dispatch.md#subscriber-error-handling). + HookErrorCategory_HOOK_ERROR_CATEGORY_TRANSFORM_FAILED HookErrorCategory = 1 + // A veto subscriber raised an error. Treated identically to an + // explicit deny — fail-closed + // (agent-loop/hook-dispatch.md#subscriber-error-handling). + HookErrorCategory_HOOK_ERROR_CATEGORY_VETO_FAILED HookErrorCategory = 2 + // The subscriber exceeded its per-subscriber deadline + // (agent-loop/hook-dispatch.md#timeout-behavior). For a veto + // subscriber, resolved identically to HOOK_ERROR_CATEGORY_VETO_FAILED — + // fail-closed deny. + HookErrorCategory_HOOK_ERROR_CATEGORY_TIMEOUT HookErrorCategory = 3 + // The subscriber's DispatchHookResponse didn't match what its declared + // HookMode requires — wrong oneof variant, or (for transform) a + // payload variant that doesn't match the request's, or a mutation to a + // field this hook point doesn't document as transform-mutable. + HookErrorCategory_HOOK_ERROR_CATEGORY_INVALID_RESPONSE HookErrorCategory = 4 + // The subscriber's plugin subprocess died mid-dispatch (transport + // error, not a graceful error the plugin chose to return). MUST be + // kernel-synthesized only, mirroring + // tool/conformance.md#error-taxonomy's TOOL_ERROR_CATEGORY_PROCESS_CRASHED + // — a plugin process that crashes obviously cannot emit this itself. + HookErrorCategory_HOOK_ERROR_CATEGORY_PROCESS_CRASHED HookErrorCategory = 5 + // Anything else. + HookErrorCategory_HOOK_ERROR_CATEGORY_UNKNOWN HookErrorCategory = 6 +) + +// Enum value maps for HookErrorCategory. +var ( + HookErrorCategory_name = map[int32]string{ + 0: "HOOK_ERROR_CATEGORY_UNSPECIFIED", + 1: "HOOK_ERROR_CATEGORY_TRANSFORM_FAILED", + 2: "HOOK_ERROR_CATEGORY_VETO_FAILED", + 3: "HOOK_ERROR_CATEGORY_TIMEOUT", + 4: "HOOK_ERROR_CATEGORY_INVALID_RESPONSE", + 5: "HOOK_ERROR_CATEGORY_PROCESS_CRASHED", + 6: "HOOK_ERROR_CATEGORY_UNKNOWN", + } + HookErrorCategory_value = map[string]int32{ + "HOOK_ERROR_CATEGORY_UNSPECIFIED": 0, + "HOOK_ERROR_CATEGORY_TRANSFORM_FAILED": 1, + "HOOK_ERROR_CATEGORY_VETO_FAILED": 2, + "HOOK_ERROR_CATEGORY_TIMEOUT": 3, + "HOOK_ERROR_CATEGORY_INVALID_RESPONSE": 4, + "HOOK_ERROR_CATEGORY_PROCESS_CRASHED": 5, + "HOOK_ERROR_CATEGORY_UNKNOWN": 6, + } +) + +func (x HookErrorCategory) Enum() *HookErrorCategory { + p := new(HookErrorCategory) + *p = x + return p +} + +func (x HookErrorCategory) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (HookErrorCategory) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_hook_v1_errors_proto_enumTypes[0].Descriptor() +} + +func (HookErrorCategory) Type() protoreflect.EnumType { + return &file_pluggableharness_hook_v1_errors_proto_enumTypes[0] +} + +func (x HookErrorCategory) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use HookErrorCategory.Descriptor instead. +func (HookErrorCategory) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_errors_proto_rawDescGZIP(), []int{0} +} + +// HookError is the structured detail describing one failed hook dispatch. +// Also the payload shape the kernel-synthesized EVENT_KIND_HOOK_ERROR +// event carries (kernel.v1.EventKind, state-backend.md §The kind enum) — +// the forthcoming event.v1 package's HookErrorEvent wraps this same +// message rather than redefining it. +type HookError struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Which hook point the failing dispatch was for. MUST be set. + Point v1.HookPoint `protobuf:"varint,1,opt,name=point,proto3,enum=pluggableharness.common.v1.HookPoint" json:"point,omitempty"` + // Which plugin build the failing subscriber was. MUST be set. + Subscriber *v1.ProducerRef `protobuf:"bytes,2,opt,name=subscriber,proto3" json:"subscriber,omitempty"` + // The HookMode the failing subscription was declared under. MUST be + // set. + Mode HookMode `protobuf:"varint,3,opt,name=mode,proto3,enum=pluggableharness.hook.v1.HookMode" json:"mode,omitempty"` + // Which category of failure this is. MUST be set. + Category HookErrorCategory `protobuf:"varint,4,opt,name=category,proto3,enum=pluggableharness.hook.v1.HookErrorCategory" json:"category,omitempty"` + // Human-readable detail, e.g. the raw subscriber error message. + Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HookError) Reset() { + *x = HookError{} + mi := &file_pluggableharness_hook_v1_errors_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HookError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HookError) ProtoMessage() {} + +func (x *HookError) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_hook_v1_errors_proto_msgTypes[0] + 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 HookError.ProtoReflect.Descriptor instead. +func (*HookError) Descriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_errors_proto_rawDescGZIP(), []int{0} +} + +func (x *HookError) GetPoint() v1.HookPoint { + if x != nil { + return x.Point + } + return v1.HookPoint(0) +} + +func (x *HookError) GetSubscriber() *v1.ProducerRef { + if x != nil { + return x.Subscriber + } + return nil +} + +func (x *HookError) GetMode() HookMode { + if x != nil { + return x.Mode + } + return HookMode_HOOK_MODE_UNSPECIFIED +} + +func (x *HookError) GetCategory() HookErrorCategory { + if x != nil { + return x.Category + } + return HookErrorCategory_HOOK_ERROR_CATEGORY_UNSPECIFIED +} + +func (x *HookError) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +var File_pluggableharness_hook_v1_errors_proto protoreflect.FileDescriptor + +const file_pluggableharness_hook_v1_errors_proto_rawDesc = "" + + "\n" + + "%pluggableharness/hook/v1/errors.proto\x12\x18pluggableharness.hook.v1\x1a&pluggableharness/common/v1/types.proto\x1a$pluggableharness/hook/v1/types.proto\"\xac\x02\n" + + "\tHookError\x12;\n" + + "\x05point\x18\x01 \x01(\x0e2%.pluggableharness.common.v1.HookPointR\x05point\x12G\n" + + "\n" + + "subscriber\x18\x02 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\n" + + "subscriber\x126\n" + + "\x04mode\x18\x03 \x01(\x0e2\".pluggableharness.hook.v1.HookModeR\x04mode\x12G\n" + + "\bcategory\x18\x04 \x01(\x0e2+.pluggableharness.hook.v1.HookErrorCategoryR\bcategory\x12\x18\n" + + "\amessage\x18\x05 \x01(\tR\amessage*\x9c\x02\n" + + "\x11HookErrorCategory\x12#\n" + + "\x1fHOOK_ERROR_CATEGORY_UNSPECIFIED\x10\x00\x12(\n" + + "$HOOK_ERROR_CATEGORY_TRANSFORM_FAILED\x10\x01\x12#\n" + + "\x1fHOOK_ERROR_CATEGORY_VETO_FAILED\x10\x02\x12\x1f\n" + + "\x1bHOOK_ERROR_CATEGORY_TIMEOUT\x10\x03\x12(\n" + + "$HOOK_ERROR_CATEGORY_INVALID_RESPONSE\x10\x04\x12'\n" + + "#HOOK_ERROR_CATEGORY_PROCESS_CRASHED\x10\x05\x12\x1f\n" + + "\x1bHOOK_ERROR_CATEGORY_UNKNOWN\x10\x06B pluggableharness.common.v1.HookPoint + 3, // 1: pluggableharness.hook.v1.HookError.subscriber:type_name -> pluggableharness.common.v1.ProducerRef + 4, // 2: pluggableharness.hook.v1.HookError.mode:type_name -> pluggableharness.hook.v1.HookMode + 0, // 3: pluggableharness.hook.v1.HookError.category:type_name -> pluggableharness.hook.v1.HookErrorCategory + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_pluggableharness_hook_v1_errors_proto_init() } +func file_pluggableharness_hook_v1_errors_proto_init() { + if File_pluggableharness_hook_v1_errors_proto != nil { + return + } + file_pluggableharness_hook_v1_types_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_hook_v1_errors_proto_rawDesc), len(file_pluggableharness_hook_v1_errors_proto_rawDesc)), + NumEnums: 1, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_hook_v1_errors_proto_goTypes, + DependencyIndexes: file_pluggableharness_hook_v1_errors_proto_depIdxs, + EnumInfos: file_pluggableharness_hook_v1_errors_proto_enumTypes, + MessageInfos: file_pluggableharness_hook_v1_errors_proto_msgTypes, + }.Build() + File_pluggableharness_hook_v1_errors_proto = out.File + file_pluggableharness_hook_v1_errors_proto_goTypes = nil + file_pluggableharness_hook_v1_errors_proto_depIdxs = nil +} diff --git a/pkg/hook/proto/v1/events.pb.go b/pkg/hook/proto/v1/events.pb.go new file mode 100644 index 0000000..7c14ef1 --- /dev/null +++ b/pkg/hook/proto/v1/events.pb.go @@ -0,0 +1,913 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/hook/v1/events.proto + +package hookv1 + +import ( + v12 "github.com/pluggableharness/agent/pkg/common/proto/v1" + v1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + v11 "github.com/pluggableharness/agent/pkg/model/proto/v1" + v14 "github.com/pluggableharness/agent/pkg/plan/proto/v1" + v15 "github.com/pluggableharness/agent/pkg/session/proto/v1" + v13 "github.com/pluggableharness/agent/pkg/tool/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// HookPayload carries one hook point's data. Exactly one oneof variant is +// set; which variant is set *is* the point being dispatched — the +// parallel common.v1.HookPoint enum exists only for contexts (HookError) +// that have no oneof to infer the point from. +type HookPayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *HookPayload_SessionStart + // *HookPayload_PreModelCall + // *HookPayload_PostModelResponse + // *HookPayload_PreToolCall + // *HookPayload_PlanReady + // *HookPayload_PostToolCall + // *HookPayload_PostApply + // *HookPayload_SessionEnd + Payload isHookPayload_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HookPayload) Reset() { + *x = HookPayload{} + mi := &file_pluggableharness_hook_v1_events_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HookPayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HookPayload) ProtoMessage() {} + +func (x *HookPayload) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_hook_v1_events_proto_msgTypes[0] + 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 HookPayload.ProtoReflect.Descriptor instead. +func (*HookPayload) Descriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_events_proto_rawDescGZIP(), []int{0} +} + +func (x *HookPayload) GetPayload() isHookPayload_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *HookPayload) GetSessionStart() *SessionStartPayload { + if x != nil { + if x, ok := x.Payload.(*HookPayload_SessionStart); ok { + return x.SessionStart + } + } + return nil +} + +func (x *HookPayload) GetPreModelCall() *PreModelCallPayload { + if x != nil { + if x, ok := x.Payload.(*HookPayload_PreModelCall); ok { + return x.PreModelCall + } + } + return nil +} + +func (x *HookPayload) GetPostModelResponse() *PostModelResponsePayload { + if x != nil { + if x, ok := x.Payload.(*HookPayload_PostModelResponse); ok { + return x.PostModelResponse + } + } + return nil +} + +func (x *HookPayload) GetPreToolCall() *PreToolCallPayload { + if x != nil { + if x, ok := x.Payload.(*HookPayload_PreToolCall); ok { + return x.PreToolCall + } + } + return nil +} + +func (x *HookPayload) GetPlanReady() *PlanReadyPayload { + if x != nil { + if x, ok := x.Payload.(*HookPayload_PlanReady); ok { + return x.PlanReady + } + } + return nil +} + +func (x *HookPayload) GetPostToolCall() *PostToolCallPayload { + if x != nil { + if x, ok := x.Payload.(*HookPayload_PostToolCall); ok { + return x.PostToolCall + } + } + return nil +} + +func (x *HookPayload) GetPostApply() *PostApplyPayload { + if x != nil { + if x, ok := x.Payload.(*HookPayload_PostApply); ok { + return x.PostApply + } + } + return nil +} + +func (x *HookPayload) GetSessionEnd() *SessionEndPayload { + if x != nil { + if x, ok := x.Payload.(*HookPayload_SessionEnd); ok { + return x.SessionEnd + } + } + return nil +} + +type isHookPayload_Payload interface { + isHookPayload_Payload() +} + +type HookPayload_SessionStart struct { + // Fires at HOOK_POINT_SESSION_START. + SessionStart *SessionStartPayload `protobuf:"bytes,1,opt,name=session_start,json=sessionStart,proto3,oneof"` +} + +type HookPayload_PreModelCall struct { + // Fires at HOOK_POINT_PRE_MODEL_CALL. + PreModelCall *PreModelCallPayload `protobuf:"bytes,2,opt,name=pre_model_call,json=preModelCall,proto3,oneof"` +} + +type HookPayload_PostModelResponse struct { + // Fires at HOOK_POINT_POST_MODEL_RESPONSE. + PostModelResponse *PostModelResponsePayload `protobuf:"bytes,3,opt,name=post_model_response,json=postModelResponse,proto3,oneof"` +} + +type HookPayload_PreToolCall struct { + // Fires at HOOK_POINT_PRE_TOOL_CALL. + PreToolCall *PreToolCallPayload `protobuf:"bytes,4,opt,name=pre_tool_call,json=preToolCall,proto3,oneof"` +} + +type HookPayload_PlanReady struct { + // Fires at HOOK_POINT_PLAN_READY. + PlanReady *PlanReadyPayload `protobuf:"bytes,5,opt,name=plan_ready,json=planReady,proto3,oneof"` +} + +type HookPayload_PostToolCall struct { + // Fires at HOOK_POINT_POST_TOOL_CALL. + PostToolCall *PostToolCallPayload `protobuf:"bytes,6,opt,name=post_tool_call,json=postToolCall,proto3,oneof"` +} + +type HookPayload_PostApply struct { + // Fires at HOOK_POINT_POST_APPLY. + PostApply *PostApplyPayload `protobuf:"bytes,7,opt,name=post_apply,json=postApply,proto3,oneof"` +} + +type HookPayload_SessionEnd struct { + // Fires at HOOK_POINT_SESSION_END. + SessionEnd *SessionEndPayload `protobuf:"bytes,8,opt,name=session_end,json=sessionEnd,proto3,oneof"` +} + +func (*HookPayload_SessionStart) isHookPayload_Payload() {} + +func (*HookPayload_PreModelCall) isHookPayload_Payload() {} + +func (*HookPayload_PostModelResponse) isHookPayload_Payload() {} + +func (*HookPayload_PreToolCall) isHookPayload_Payload() {} + +func (*HookPayload_PlanReady) isHookPayload_Payload() {} + +func (*HookPayload_PostToolCall) isHookPayload_Payload() {} + +func (*HookPayload_PostApply) isHookPayload_Payload() {} + +func (*HookPayload_SessionEnd) isHookPayload_Payload() {} + +// SessionStartPayload fires once, at session creation. No field here is +// transform-mutable per agent-loop/hook-dispatch.md's per-point mutable- +// field table — session identity and startup parameters are fixed by the +// time this hook fires. +type SessionStartPayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The new session's id. MUST be set. Immutable. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // The agent.hcl profile this session was created under. MUST be set. + // Immutable. + Profile string `protobuf:"bytes,2,opt,name=profile,proto3" json:"profile,omitempty"` + // The parent session's id, when this is a sub-agent session + // (agent-loop/subagents.md). Absent for a root session. Immutable. + ParentSessionId *string `protobuf:"bytes,3,opt,name=parent_session_id,json=parentSessionId,proto3,oneof" json:"parent_session_id,omitempty"` + // The session's working directory. MUST be set. Immutable. + WorkingDirectory string `protobuf:"bytes,4,opt,name=working_directory,json=workingDirectory,proto3" json:"working_directory,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionStartPayload) Reset() { + *x = SessionStartPayload{} + mi := &file_pluggableharness_hook_v1_events_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionStartPayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionStartPayload) ProtoMessage() {} + +func (x *SessionStartPayload) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_hook_v1_events_proto_msgTypes[1] + 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 SessionStartPayload.ProtoReflect.Descriptor instead. +func (*SessionStartPayload) Descriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_events_proto_rawDescGZIP(), []int{1} +} + +func (x *SessionStartPayload) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *SessionStartPayload) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +func (x *SessionStartPayload) GetParentSessionId() string { + if x != nil && x.ParentSessionId != nil { + return *x.ParentSessionId + } + return "" +} + +func (x *SessionStartPayload) GetWorkingDirectory() string { + if x != nil { + return x.WorkingDirectory + } + return "" +} + +// PreModelCallPayload fires immediately before a model provider's +// StreamCompletion is invoked. A transform subscriber MAY rewrite +// `messages` (e.g. redaction, injection of an additional instruction); it +// MUST NOT alter `model` — a hook subscriber does not get to silently +// reroute a turn to a different model than the one the turn algorithm +// already resolved. +type PreModelCallPayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The messages about to be sent to the model. Transform-mutable. + Messages []*v1.Message `protobuf:"bytes,1,rep,name=messages,proto3" json:"messages,omitempty"` + // The model this call targets. Immutable. + Model *v11.ModelRef `protobuf:"bytes,2,opt,name=model,proto3" json:"model,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PreModelCallPayload) Reset() { + *x = PreModelCallPayload{} + mi := &file_pluggableharness_hook_v1_events_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PreModelCallPayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PreModelCallPayload) ProtoMessage() {} + +func (x *PreModelCallPayload) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_hook_v1_events_proto_msgTypes[2] + 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 PreModelCallPayload.ProtoReflect.Descriptor instead. +func (*PreModelCallPayload) Descriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_events_proto_rawDescGZIP(), []int{2} +} + +func (x *PreModelCallPayload) GetMessages() []*v1.Message { + if x != nil { + return x.Messages + } + return nil +} + +func (x *PreModelCallPayload) GetModel() *v11.ModelRef { + if x != nil { + return x.Model + } + return nil +} + +// PostModelResponsePayload fires immediately after a model turn's +// canonical message has been assembled, before it is persisted +// (EVENT_KIND_MESSAGE). Primarily an observe-mode point (memory providers +// recording the turn, widgets displaying live usage) — no field here is +// documented transform-mutable in agent-loop/hook-dispatch.md's per-point +// table; a transform subscriber at this point MUST return the payload +// unchanged. +type PostModelResponsePayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The assembled assistant message. MUST be set. + Message *v1.Message `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + // Which model-provider build produced `message`, for supersedes/replay + // attribution (architecture.md §Versioning & schema drift — + // "supersedes"). MUST be set. + Model *v12.ProducerRef `protobuf:"bytes,2,opt,name=model,proto3" json:"model,omitempty"` + // Token usage for the completion that produced `message`. MUST be set. + Usage *v11.Usage `protobuf:"bytes,3,opt,name=usage,proto3" json:"usage,omitempty"` + // The kernel-computed cost, in USD, of the completion that produced + // `message` (model/protocol.md#cost-computation — the provider never + // computes cost itself). MUST be set. + CostUsd float64 `protobuf:"fixed64,4,opt,name=cost_usd,json=costUsd,proto3" json:"cost_usd,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PostModelResponsePayload) Reset() { + *x = PostModelResponsePayload{} + mi := &file_pluggableharness_hook_v1_events_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PostModelResponsePayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PostModelResponsePayload) ProtoMessage() {} + +func (x *PostModelResponsePayload) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_hook_v1_events_proto_msgTypes[3] + 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 PostModelResponsePayload.ProtoReflect.Descriptor instead. +func (*PostModelResponsePayload) Descriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_events_proto_rawDescGZIP(), []int{3} +} + +func (x *PostModelResponsePayload) GetMessage() *v1.Message { + if x != nil { + return x.Message + } + return nil +} + +func (x *PostModelResponsePayload) GetModel() *v12.ProducerRef { + if x != nil { + return x.Model + } + return nil +} + +func (x *PostModelResponsePayload) GetUsage() *v11.Usage { + if x != nil { + return x.Usage + } + return nil +} + +func (x *PostModelResponsePayload) GetCostUsd() float64 { + if x != nil { + return x.CostUsd + } + return 0 +} + +// PreToolCallPayload fires immediately before an already-allowed plan +// item's tool call is applied. No field here is documented transform- +// mutable — a hook subscriber observes or vetoes an about-to-execute call, +// it does not get to silently rewrite its arguments; argument mutation is +// the plan/apply gate's own concern (agent-loop.md §5), not a general hook +// capability. +type PreToolCallPayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The call about to be applied. MUST be set. Immutable. + Call *v13.ToolCall `protobuf:"bytes,1,opt,name=call,proto3" json:"call,omitempty"` + // The originating plan item, carrying the policy decision that allowed + // this call through to apply. MUST be set. Immutable. + PlanItem *v14.PlanItem `protobuf:"bytes,2,opt,name=plan_item,json=planItem,proto3" json:"plan_item,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PreToolCallPayload) Reset() { + *x = PreToolCallPayload{} + mi := &file_pluggableharness_hook_v1_events_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PreToolCallPayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PreToolCallPayload) ProtoMessage() {} + +func (x *PreToolCallPayload) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_hook_v1_events_proto_msgTypes[4] + 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 PreToolCallPayload.ProtoReflect.Descriptor instead. +func (*PreToolCallPayload) Descriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_events_proto_rawDescGZIP(), []int{4} +} + +func (x *PreToolCallPayload) GetCall() *v13.ToolCall { + if x != nil { + return x.Call + } + return nil +} + +func (x *PreToolCallPayload) GetPlanItem() *v14.PlanItem { + if x != nil { + return x.PlanItem + } + return nil +} + +// PlanReadyPayload fires once a turn's Plan has been fully built, before +// the plan/apply gate dispatches it. This is the veto-bearing hook point: +// the kernel-privileged policy subscriber (architecture.md §Policy — +// first-party, not a plugin category) always runs here in HOOK_MODE_VETO. +// No field here is transform-mutable — a hook subscriber does not rewrite +// plan items; only the plan/apply gate itself and the policy veto affect +// what applies. +type PlanReadyPayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The fully-built plan awaiting policy evaluation. MUST be set. + // Immutable. + Plan *v14.Plan `protobuf:"bytes,1,opt,name=plan,proto3" json:"plan,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PlanReadyPayload) Reset() { + *x = PlanReadyPayload{} + mi := &file_pluggableharness_hook_v1_events_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PlanReadyPayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PlanReadyPayload) ProtoMessage() {} + +func (x *PlanReadyPayload) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_hook_v1_events_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 PlanReadyPayload.ProtoReflect.Descriptor instead. +func (*PlanReadyPayload) Descriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_events_proto_rawDescGZIP(), []int{5} +} + +func (x *PlanReadyPayload) GetPlan() *v14.Plan { + if x != nil { + return x.Plan + } + return nil +} + +// PostToolCallPayload fires immediately after a plan item's tool call +// reaches a terminal outcome. Observe-only in practice — no field here is +// documented transform-mutable. +type PostToolCallPayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The call that completed. MUST be set. Immutable. + Call *v13.ToolCall `protobuf:"bytes,1,opt,name=call,proto3" json:"call,omitempty"` + // Types that are valid to be assigned to Outcome: + // + // *PostToolCallPayload_Result + // *PostToolCallPayload_Error + Outcome isPostToolCallPayload_Outcome `protobuf_oneof:"outcome"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PostToolCallPayload) Reset() { + *x = PostToolCallPayload{} + mi := &file_pluggableharness_hook_v1_events_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PostToolCallPayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PostToolCallPayload) ProtoMessage() {} + +func (x *PostToolCallPayload) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_hook_v1_events_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 PostToolCallPayload.ProtoReflect.Descriptor instead. +func (*PostToolCallPayload) Descriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_events_proto_rawDescGZIP(), []int{6} +} + +func (x *PostToolCallPayload) GetCall() *v13.ToolCall { + if x != nil { + return x.Call + } + return nil +} + +func (x *PostToolCallPayload) GetOutcome() isPostToolCallPayload_Outcome { + if x != nil { + return x.Outcome + } + return nil +} + +func (x *PostToolCallPayload) GetResult() *v13.ToolResult { + if x != nil { + if x, ok := x.Outcome.(*PostToolCallPayload_Result); ok { + return x.Result + } + } + return nil +} + +func (x *PostToolCallPayload) GetError() *v13.ToolError { + if x != nil { + if x, ok := x.Outcome.(*PostToolCallPayload_Error); ok { + return x.Error + } + } + return nil +} + +type isPostToolCallPayload_Outcome interface { + isPostToolCallPayload_Outcome() +} + +type PostToolCallPayload_Result struct { + // The call's successful terminal result. + Result *v13.ToolResult `protobuf:"bytes,2,opt,name=result,proto3,oneof"` +} + +type PostToolCallPayload_Error struct { + // The call's failed terminal result. + Error *v13.ToolError `protobuf:"bytes,3,opt,name=error,proto3,oneof"` +} + +func (*PostToolCallPayload_Result) isPostToolCallPayload_Outcome() {} + +func (*PostToolCallPayload_Error) isPostToolCallPayload_Outcome() {} + +// PostApplyPayload fires once a turn's whole Plan has finished applying — +// every item has reached a terminal ApplyOutcome +// (pluggableharness.plan.v1.ApplyResult.ApplyOutcome). Reuses +// plan.v1.ApplyResult rather than defining its own per-item outcome shape, +// so the post-apply hook's subject and the EVENT_KIND_APPLY event (the +// forthcoming event.v1 package) are the exact same message. Observe-only — +// applying has already happened by the time this fires, so there is +// nothing left to transform. +type PostApplyPayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The turn's per-item apply outcomes. MUST be set. + Apply *v14.ApplyResult `protobuf:"bytes,1,opt,name=apply,proto3" json:"apply,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PostApplyPayload) Reset() { + *x = PostApplyPayload{} + mi := &file_pluggableharness_hook_v1_events_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PostApplyPayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PostApplyPayload) ProtoMessage() {} + +func (x *PostApplyPayload) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_hook_v1_events_proto_msgTypes[7] + 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 PostApplyPayload.ProtoReflect.Descriptor instead. +func (*PostApplyPayload) Descriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_events_proto_rawDescGZIP(), []int{7} +} + +func (x *PostApplyPayload) GetApply() *v14.ApplyResult { + if x != nil { + return x.Apply + } + return nil +} + +// SessionEndPayload fires once, when a session reaches a terminal +// SessionStatus. No field here is transform-mutable — a session's outcome +// is already final by the time this fires. +type SessionEndPayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session that ended. MUST be set. Immutable. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // The session's terminal status. MUST be set (never + // SESSION_STATUS_RUNNING). Immutable. + Status v15.SessionStatus `protobuf:"varint,2,opt,name=status,proto3,enum=pluggableharness.session.v1.SessionStatus" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionEndPayload) Reset() { + *x = SessionEndPayload{} + mi := &file_pluggableharness_hook_v1_events_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionEndPayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionEndPayload) ProtoMessage() {} + +func (x *SessionEndPayload) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_hook_v1_events_proto_msgTypes[8] + 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 SessionEndPayload.ProtoReflect.Descriptor instead. +func (*SessionEndPayload) Descriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_events_proto_rawDescGZIP(), []int{8} +} + +func (x *SessionEndPayload) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *SessionEndPayload) GetStatus() v15.SessionStatus { + if x != nil { + return x.Status + } + return v15.SessionStatus(0) +} + +var File_pluggableharness_hook_v1_events_proto protoreflect.FileDescriptor + +const file_pluggableharness_hook_v1_events_proto_rawDesc = "" + + "\n" + + "%pluggableharness/hook/v1/events.proto\x12\x18pluggableharness.hook.v1\x1a&pluggableharness/common/v1/types.proto\x1a'pluggableharness/content/v1/types.proto\x1a%pluggableharness/model/v1/types.proto\x1a$pluggableharness/plan/v1/types.proto\x1a'pluggableharness/session/v1/types.proto\x1a%pluggableharness/tool/v1/errors.proto\x1a$pluggableharness/tool/v1/types.proto\"\xc0\x05\n" + + "\vHookPayload\x12T\n" + + "\rsession_start\x18\x01 \x01(\v2-.pluggableharness.hook.v1.SessionStartPayloadH\x00R\fsessionStart\x12U\n" + + "\x0epre_model_call\x18\x02 \x01(\v2-.pluggableharness.hook.v1.PreModelCallPayloadH\x00R\fpreModelCall\x12d\n" + + "\x13post_model_response\x18\x03 \x01(\v22.pluggableharness.hook.v1.PostModelResponsePayloadH\x00R\x11postModelResponse\x12R\n" + + "\rpre_tool_call\x18\x04 \x01(\v2,.pluggableharness.hook.v1.PreToolCallPayloadH\x00R\vpreToolCall\x12K\n" + + "\n" + + "plan_ready\x18\x05 \x01(\v2*.pluggableharness.hook.v1.PlanReadyPayloadH\x00R\tplanReady\x12U\n" + + "\x0epost_tool_call\x18\x06 \x01(\v2-.pluggableharness.hook.v1.PostToolCallPayloadH\x00R\fpostToolCall\x12K\n" + + "\n" + + "post_apply\x18\a \x01(\v2*.pluggableharness.hook.v1.PostApplyPayloadH\x00R\tpostApply\x12N\n" + + "\vsession_end\x18\b \x01(\v2+.pluggableharness.hook.v1.SessionEndPayloadH\x00R\n" + + "sessionEndB\t\n" + + "\apayload\"\xc2\x01\n" + + "\x13SessionStartPayload\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x18\n" + + "\aprofile\x18\x02 \x01(\tR\aprofile\x12/\n" + + "\x11parent_session_id\x18\x03 \x01(\tH\x00R\x0fparentSessionId\x88\x01\x01\x12+\n" + + "\x11working_directory\x18\x04 \x01(\tR\x10workingDirectoryB\x14\n" + + "\x12_parent_session_id\"\x92\x01\n" + + "\x13PreModelCallPayload\x12@\n" + + "\bmessages\x18\x01 \x03(\v2$.pluggableharness.content.v1.MessageR\bmessages\x129\n" + + "\x05model\x18\x02 \x01(\v2#.pluggableharness.model.v1.ModelRefR\x05model\"\xec\x01\n" + + "\x18PostModelResponsePayload\x12>\n" + + "\amessage\x18\x01 \x01(\v2$.pluggableharness.content.v1.MessageR\amessage\x12=\n" + + "\x05model\x18\x02 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\x05model\x126\n" + + "\x05usage\x18\x03 \x01(\v2 .pluggableharness.model.v1.UsageR\x05usage\x12\x19\n" + + "\bcost_usd\x18\x04 \x01(\x01R\acostUsd\"\x8d\x01\n" + + "\x12PreToolCallPayload\x126\n" + + "\x04call\x18\x01 \x01(\v2\".pluggableharness.tool.v1.ToolCallR\x04call\x12?\n" + + "\tplan_item\x18\x02 \x01(\v2\".pluggableharness.plan.v1.PlanItemR\bplanItem\"F\n" + + "\x10PlanReadyPayload\x122\n" + + "\x04plan\x18\x01 \x01(\v2\x1e.pluggableharness.plan.v1.PlanR\x04plan\"\xd5\x01\n" + + "\x13PostToolCallPayload\x126\n" + + "\x04call\x18\x01 \x01(\v2\".pluggableharness.tool.v1.ToolCallR\x04call\x12>\n" + + "\x06result\x18\x02 \x01(\v2$.pluggableharness.tool.v1.ToolResultH\x00R\x06result\x12;\n" + + "\x05error\x18\x03 \x01(\v2#.pluggableharness.tool.v1.ToolErrorH\x00R\x05errorB\t\n" + + "\aoutcome\"O\n" + + "\x10PostApplyPayload\x12;\n" + + "\x05apply\x18\x01 \x01(\v2%.pluggableharness.plan.v1.ApplyResultR\x05apply\"v\n" + + "\x11SessionEndPayload\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12B\n" + + "\x06status\x18\x02 \x01(\x0e2*.pluggableharness.session.v1.SessionStatusR\x06statusB pluggableharness.hook.v1.SessionStartPayload + 2, // 1: pluggableharness.hook.v1.HookPayload.pre_model_call:type_name -> pluggableharness.hook.v1.PreModelCallPayload + 3, // 2: pluggableharness.hook.v1.HookPayload.post_model_response:type_name -> pluggableharness.hook.v1.PostModelResponsePayload + 4, // 3: pluggableharness.hook.v1.HookPayload.pre_tool_call:type_name -> pluggableharness.hook.v1.PreToolCallPayload + 5, // 4: pluggableharness.hook.v1.HookPayload.plan_ready:type_name -> pluggableharness.hook.v1.PlanReadyPayload + 6, // 5: pluggableharness.hook.v1.HookPayload.post_tool_call:type_name -> pluggableharness.hook.v1.PostToolCallPayload + 7, // 6: pluggableharness.hook.v1.HookPayload.post_apply:type_name -> pluggableharness.hook.v1.PostApplyPayload + 8, // 7: pluggableharness.hook.v1.HookPayload.session_end:type_name -> pluggableharness.hook.v1.SessionEndPayload + 9, // 8: pluggableharness.hook.v1.PreModelCallPayload.messages:type_name -> pluggableharness.content.v1.Message + 10, // 9: pluggableharness.hook.v1.PreModelCallPayload.model:type_name -> pluggableharness.model.v1.ModelRef + 9, // 10: pluggableharness.hook.v1.PostModelResponsePayload.message:type_name -> pluggableharness.content.v1.Message + 11, // 11: pluggableharness.hook.v1.PostModelResponsePayload.model:type_name -> pluggableharness.common.v1.ProducerRef + 12, // 12: pluggableharness.hook.v1.PostModelResponsePayload.usage:type_name -> pluggableharness.model.v1.Usage + 13, // 13: pluggableharness.hook.v1.PreToolCallPayload.call:type_name -> pluggableharness.tool.v1.ToolCall + 14, // 14: pluggableharness.hook.v1.PreToolCallPayload.plan_item:type_name -> pluggableharness.plan.v1.PlanItem + 15, // 15: pluggableharness.hook.v1.PlanReadyPayload.plan:type_name -> pluggableharness.plan.v1.Plan + 13, // 16: pluggableharness.hook.v1.PostToolCallPayload.call:type_name -> pluggableharness.tool.v1.ToolCall + 16, // 17: pluggableharness.hook.v1.PostToolCallPayload.result:type_name -> pluggableharness.tool.v1.ToolResult + 17, // 18: pluggableharness.hook.v1.PostToolCallPayload.error:type_name -> pluggableharness.tool.v1.ToolError + 18, // 19: pluggableharness.hook.v1.PostApplyPayload.apply:type_name -> pluggableharness.plan.v1.ApplyResult + 19, // 20: pluggableharness.hook.v1.SessionEndPayload.status:type_name -> pluggableharness.session.v1.SessionStatus + 21, // [21:21] is the sub-list for method output_type + 21, // [21:21] is the sub-list for method input_type + 21, // [21:21] is the sub-list for extension type_name + 21, // [21:21] is the sub-list for extension extendee + 0, // [0:21] is the sub-list for field type_name +} + +func init() { file_pluggableharness_hook_v1_events_proto_init() } +func file_pluggableharness_hook_v1_events_proto_init() { + if File_pluggableharness_hook_v1_events_proto != nil { + return + } + file_pluggableharness_hook_v1_events_proto_msgTypes[0].OneofWrappers = []any{ + (*HookPayload_SessionStart)(nil), + (*HookPayload_PreModelCall)(nil), + (*HookPayload_PostModelResponse)(nil), + (*HookPayload_PreToolCall)(nil), + (*HookPayload_PlanReady)(nil), + (*HookPayload_PostToolCall)(nil), + (*HookPayload_PostApply)(nil), + (*HookPayload_SessionEnd)(nil), + } + file_pluggableharness_hook_v1_events_proto_msgTypes[1].OneofWrappers = []any{} + file_pluggableharness_hook_v1_events_proto_msgTypes[6].OneofWrappers = []any{ + (*PostToolCallPayload_Result)(nil), + (*PostToolCallPayload_Error)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_hook_v1_events_proto_rawDesc), len(file_pluggableharness_hook_v1_events_proto_rawDesc)), + NumEnums: 0, + NumMessages: 9, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_hook_v1_events_proto_goTypes, + DependencyIndexes: file_pluggableharness_hook_v1_events_proto_depIdxs, + MessageInfos: file_pluggableharness_hook_v1_events_proto_msgTypes, + }.Build() + File_pluggableharness_hook_v1_events_proto = out.File + file_pluggableharness_hook_v1_events_proto_goTypes = nil + file_pluggableharness_hook_v1_events_proto_depIdxs = nil +} diff --git a/pkg/hook/proto/v1/hook.pb.go b/pkg/hook/proto/v1/hook.pb.go deleted file mode 100644 index 2b78deb..0000000 --- a/pkg/hook/proto/v1/hook.pb.go +++ /dev/null @@ -1,1636 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: pluggableharness/hook/v1/hook.proto - -// Package pluggableharness.hook.v1 defines the hook-dispatch RPC surface -// described in agent-loop/hook-dispatch.md and architecture.md §Hook -// dispatch semantics: the wire contract the kernel uses to invoke any -// plugin (of any of the six categories) that declares a `hook{}` block in -// agent.hcl. This is deliberately one shared service rather than a -// per-category RPC — hashicorp/go-plugin (.claude/rules/plugin-runtime.md) -// muxes multiple gRPC services over one broker connection, so the kernel -// dials HookSubscriberService on the same subprocess that already serves -// that plugin's own category service. A plugin with no `hook{}` blocks in -// agent.hcl simply never has it called. -// -// context-assemble is deliberately absent from this surface's hook-point -// vocabulary (common.v1.HookPoint) — it stays on ContextService.Contribute -// (context/protocol.md#contribute-the-context-assemble-rpc; architecture.md -// §Hook dispatch semantics), which already carries the full accumulated -// ContextSection chain and doesn't need a second, competing dispatch path. -// This surface serves the other eight hook points only. - -package hookv1 - -import ( - v12 "github.com/pluggableharness/agent/pkg/common/proto/v1" - v1 "github.com/pluggableharness/agent/pkg/content/proto/v1" - v11 "github.com/pluggableharness/agent/pkg/model/proto/v1" - v14 "github.com/pluggableharness/agent/pkg/plan/proto/v1" - v15 "github.com/pluggableharness/agent/pkg/session/proto/v1" - v13 "github.com/pluggableharness/agent/pkg/tool/proto/v1" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// HookMode is the operator-declared subscription mode for one plugin's -// hook{} block, per architecture.md §Hook dispatch semantics and -// agent-loop/hook-dispatch.md#dispatch-order-and-payload-flow. Mode is -// per-subscription (agent.hcl-declared), not per-payload — it is not -// carried in HookPayload itself, only echoed on DispatchHookRequest so the -// subscriber knows which of DispatchHookResponse's three outcome shapes is -// expected of it. -type HookMode int32 - -const ( - // Zero value. Never valid on the wire; its presence means a caller - // forgot to set the field. - HookMode_HOOK_MODE_UNSPECIFIED HookMode = 0 - // Read-only, fire-and-forget. A raised error or malformed response is - // logged and dispatch continues; an observe subscriber can never alter - // the payload or abort the chain - // (agent-loop/hook-dispatch.md#subscriber-error-handling). - HookMode_HOOK_MODE_OBSERVE HookMode = 1 - // Sequential chain member: receives the prior stage's payload, returns - // a modified version of the same variant. An error or malformed - // response aborts the remainder of that hook's chain and surfaces a - // hook_error event (agent-loop/hook-dispatch.md#subscriber-error-handling). - HookMode_HOOK_MODE_TRANSFORM HookMode = 2 - // Returns an explicit allow/deny verdict. An error or timeout is - // treated identically to an explicit deny — fail-closed - // (agent-loop/hook-dispatch.md#timeout-behavior). - HookMode_HOOK_MODE_VETO HookMode = 3 -) - -// Enum value maps for HookMode. -var ( - HookMode_name = map[int32]string{ - 0: "HOOK_MODE_UNSPECIFIED", - 1: "HOOK_MODE_OBSERVE", - 2: "HOOK_MODE_TRANSFORM", - 3: "HOOK_MODE_VETO", - } - HookMode_value = map[string]int32{ - "HOOK_MODE_UNSPECIFIED": 0, - "HOOK_MODE_OBSERVE": 1, - "HOOK_MODE_TRANSFORM": 2, - "HOOK_MODE_VETO": 3, - } -) - -func (x HookMode) Enum() *HookMode { - p := new(HookMode) - *p = x - return p -} - -func (x HookMode) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (HookMode) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_hook_v1_hook_proto_enumTypes[0].Descriptor() -} - -func (HookMode) Type() protoreflect.EnumType { - return &file_pluggableharness_hook_v1_hook_proto_enumTypes[0] -} - -func (x HookMode) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use HookMode.Descriptor instead. -func (HookMode) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{0} -} - -// HookDecision is a veto subscriber's coarse allow/deny verdict over a -// whole HookPayload. Deliberately distinct from -// pluggableharness.plan.v1.PlanDecision — that enum is per-plan-item -// and carries PENDING/ASK, which are meaningless for a hook-level veto; -// the two enums MUST NOT be merged, per .claude/rules/proto.md's -// no-untyped-overload discipline (see also ToolErrorCategory vs -// ModelErrorCategory for the same non-merge precedent). -type HookDecision int32 - -const ( - // Zero value. Never valid as a deliberately-chosen verdict; its - // presence on the wire means a caller forgot to set the field. - HookDecision_HOOK_DECISION_UNSPECIFIED HookDecision = 0 - // The dispatch chain proceeds normally. - HookDecision_HOOK_DECISION_ALLOW HookDecision = 1 - // The kernel MUST NOT let the vetoed action proceed. What "the action" - // means is point-specific: at HOOK_POINT_PLAN_READY it means the whole - // plan (or, for the kernel-privileged policy subscriber, the - // per-item decisions policy itself produces directly — a third-party - // veto subscriber at plan-ready returns only this coarse ALLOW/DENY - // over the whole plan, per architecture.md §Policy — first-party, not a - // plugin category). - HookDecision_HOOK_DECISION_DENY HookDecision = 2 -) - -// Enum value maps for HookDecision. -var ( - HookDecision_name = map[int32]string{ - 0: "HOOK_DECISION_UNSPECIFIED", - 1: "HOOK_DECISION_ALLOW", - 2: "HOOK_DECISION_DENY", - } - HookDecision_value = map[string]int32{ - "HOOK_DECISION_UNSPECIFIED": 0, - "HOOK_DECISION_ALLOW": 1, - "HOOK_DECISION_DENY": 2, - } -) - -func (x HookDecision) Enum() *HookDecision { - p := new(HookDecision) - *p = x - return p -} - -func (x HookDecision) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (HookDecision) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_hook_v1_hook_proto_enumTypes[1].Descriptor() -} - -func (HookDecision) Type() protoreflect.EnumType { - return &file_pluggableharness_hook_v1_hook_proto_enumTypes[1] -} - -func (x HookDecision) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use HookDecision.Descriptor instead. -func (HookDecision) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{1} -} - -// HookErrorCategory classifies why a hook dispatch to one subscriber -// failed. Deliberately its own enum, not reused from tool.v1 or model.v1 — -// per .claude/rules/proto.md, a category proto's error taxonomy is never -// merged with another category's even where the surrounding shape is -// parallel. -type HookErrorCategory int32 - -const ( - // Zero value. Never valid for a real error; its presence on the wire - // means a caller forgot to set the field. - HookErrorCategory_HOOK_ERROR_CATEGORY_UNSPECIFIED HookErrorCategory = 0 - // A transform subscriber raised an error or returned a malformed - // response. Aborts the remainder of that hook's chain - // (agent-loop/hook-dispatch.md#subscriber-error-handling). - HookErrorCategory_HOOK_ERROR_CATEGORY_TRANSFORM_FAILED HookErrorCategory = 1 - // A veto subscriber raised an error. Treated identically to an - // explicit deny — fail-closed - // (agent-loop/hook-dispatch.md#subscriber-error-handling). - HookErrorCategory_HOOK_ERROR_CATEGORY_VETO_FAILED HookErrorCategory = 2 - // The subscriber exceeded its per-subscriber deadline - // (agent-loop/hook-dispatch.md#timeout-behavior). For a veto - // subscriber, resolved identically to HOOK_ERROR_CATEGORY_VETO_FAILED — - // fail-closed deny. - HookErrorCategory_HOOK_ERROR_CATEGORY_TIMEOUT HookErrorCategory = 3 - // The subscriber's DispatchHookResponse didn't match what its declared - // HookMode requires — wrong oneof variant, or (for transform) a - // payload variant that doesn't match the request's, or a mutation to a - // field this hook point doesn't document as transform-mutable. - HookErrorCategory_HOOK_ERROR_CATEGORY_INVALID_RESPONSE HookErrorCategory = 4 - // The subscriber's plugin subprocess died mid-dispatch (transport - // error, not a graceful error the plugin chose to return). MUST be - // kernel-synthesized only, mirroring - // tool/conformance.md#error-taxonomy's TOOL_ERROR_CATEGORY_PROCESS_CRASHED - // — a plugin process that crashes obviously cannot emit this itself. - HookErrorCategory_HOOK_ERROR_CATEGORY_PROCESS_CRASHED HookErrorCategory = 5 - // Anything else. - HookErrorCategory_HOOK_ERROR_CATEGORY_UNKNOWN HookErrorCategory = 6 -) - -// Enum value maps for HookErrorCategory. -var ( - HookErrorCategory_name = map[int32]string{ - 0: "HOOK_ERROR_CATEGORY_UNSPECIFIED", - 1: "HOOK_ERROR_CATEGORY_TRANSFORM_FAILED", - 2: "HOOK_ERROR_CATEGORY_VETO_FAILED", - 3: "HOOK_ERROR_CATEGORY_TIMEOUT", - 4: "HOOK_ERROR_CATEGORY_INVALID_RESPONSE", - 5: "HOOK_ERROR_CATEGORY_PROCESS_CRASHED", - 6: "HOOK_ERROR_CATEGORY_UNKNOWN", - } - HookErrorCategory_value = map[string]int32{ - "HOOK_ERROR_CATEGORY_UNSPECIFIED": 0, - "HOOK_ERROR_CATEGORY_TRANSFORM_FAILED": 1, - "HOOK_ERROR_CATEGORY_VETO_FAILED": 2, - "HOOK_ERROR_CATEGORY_TIMEOUT": 3, - "HOOK_ERROR_CATEGORY_INVALID_RESPONSE": 4, - "HOOK_ERROR_CATEGORY_PROCESS_CRASHED": 5, - "HOOK_ERROR_CATEGORY_UNKNOWN": 6, - } -) - -func (x HookErrorCategory) Enum() *HookErrorCategory { - p := new(HookErrorCategory) - *p = x - return p -} - -func (x HookErrorCategory) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (HookErrorCategory) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_hook_v1_hook_proto_enumTypes[2].Descriptor() -} - -func (HookErrorCategory) Type() protoreflect.EnumType { - return &file_pluggableharness_hook_v1_hook_proto_enumTypes[2] -} - -func (x HookErrorCategory) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use HookErrorCategory.Descriptor instead. -func (HookErrorCategory) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{2} -} - -// HookPayload carries one hook point's data. Exactly one oneof variant is -// set; which variant is set *is* the point being dispatched — the -// parallel common.v1.HookPoint enum exists only for contexts (HookError) -// that have no oneof to infer the point from. -type HookPayload struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Types that are valid to be assigned to Payload: - // - // *HookPayload_SessionStart - // *HookPayload_PreModelCall - // *HookPayload_PostModelResponse - // *HookPayload_PreToolCall - // *HookPayload_PlanReady - // *HookPayload_PostToolCall - // *HookPayload_PostApply - // *HookPayload_SessionEnd - Payload isHookPayload_Payload `protobuf_oneof:"payload"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *HookPayload) Reset() { - *x = HookPayload{} - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *HookPayload) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*HookPayload) ProtoMessage() {} - -func (x *HookPayload) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[0] - 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 HookPayload.ProtoReflect.Descriptor instead. -func (*HookPayload) Descriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{0} -} - -func (x *HookPayload) GetPayload() isHookPayload_Payload { - if x != nil { - return x.Payload - } - return nil -} - -func (x *HookPayload) GetSessionStart() *SessionStartPayload { - if x != nil { - if x, ok := x.Payload.(*HookPayload_SessionStart); ok { - return x.SessionStart - } - } - return nil -} - -func (x *HookPayload) GetPreModelCall() *PreModelCallPayload { - if x != nil { - if x, ok := x.Payload.(*HookPayload_PreModelCall); ok { - return x.PreModelCall - } - } - return nil -} - -func (x *HookPayload) GetPostModelResponse() *PostModelResponsePayload { - if x != nil { - if x, ok := x.Payload.(*HookPayload_PostModelResponse); ok { - return x.PostModelResponse - } - } - return nil -} - -func (x *HookPayload) GetPreToolCall() *PreToolCallPayload { - if x != nil { - if x, ok := x.Payload.(*HookPayload_PreToolCall); ok { - return x.PreToolCall - } - } - return nil -} - -func (x *HookPayload) GetPlanReady() *PlanReadyPayload { - if x != nil { - if x, ok := x.Payload.(*HookPayload_PlanReady); ok { - return x.PlanReady - } - } - return nil -} - -func (x *HookPayload) GetPostToolCall() *PostToolCallPayload { - if x != nil { - if x, ok := x.Payload.(*HookPayload_PostToolCall); ok { - return x.PostToolCall - } - } - return nil -} - -func (x *HookPayload) GetPostApply() *PostApplyPayload { - if x != nil { - if x, ok := x.Payload.(*HookPayload_PostApply); ok { - return x.PostApply - } - } - return nil -} - -func (x *HookPayload) GetSessionEnd() *SessionEndPayload { - if x != nil { - if x, ok := x.Payload.(*HookPayload_SessionEnd); ok { - return x.SessionEnd - } - } - return nil -} - -type isHookPayload_Payload interface { - isHookPayload_Payload() -} - -type HookPayload_SessionStart struct { - // Fires at HOOK_POINT_SESSION_START. - SessionStart *SessionStartPayload `protobuf:"bytes,1,opt,name=session_start,json=sessionStart,proto3,oneof"` -} - -type HookPayload_PreModelCall struct { - // Fires at HOOK_POINT_PRE_MODEL_CALL. - PreModelCall *PreModelCallPayload `protobuf:"bytes,2,opt,name=pre_model_call,json=preModelCall,proto3,oneof"` -} - -type HookPayload_PostModelResponse struct { - // Fires at HOOK_POINT_POST_MODEL_RESPONSE. - PostModelResponse *PostModelResponsePayload `protobuf:"bytes,3,opt,name=post_model_response,json=postModelResponse,proto3,oneof"` -} - -type HookPayload_PreToolCall struct { - // Fires at HOOK_POINT_PRE_TOOL_CALL. - PreToolCall *PreToolCallPayload `protobuf:"bytes,4,opt,name=pre_tool_call,json=preToolCall,proto3,oneof"` -} - -type HookPayload_PlanReady struct { - // Fires at HOOK_POINT_PLAN_READY. - PlanReady *PlanReadyPayload `protobuf:"bytes,5,opt,name=plan_ready,json=planReady,proto3,oneof"` -} - -type HookPayload_PostToolCall struct { - // Fires at HOOK_POINT_POST_TOOL_CALL. - PostToolCall *PostToolCallPayload `protobuf:"bytes,6,opt,name=post_tool_call,json=postToolCall,proto3,oneof"` -} - -type HookPayload_PostApply struct { - // Fires at HOOK_POINT_POST_APPLY. - PostApply *PostApplyPayload `protobuf:"bytes,7,opt,name=post_apply,json=postApply,proto3,oneof"` -} - -type HookPayload_SessionEnd struct { - // Fires at HOOK_POINT_SESSION_END. - SessionEnd *SessionEndPayload `protobuf:"bytes,8,opt,name=session_end,json=sessionEnd,proto3,oneof"` -} - -func (*HookPayload_SessionStart) isHookPayload_Payload() {} - -func (*HookPayload_PreModelCall) isHookPayload_Payload() {} - -func (*HookPayload_PostModelResponse) isHookPayload_Payload() {} - -func (*HookPayload_PreToolCall) isHookPayload_Payload() {} - -func (*HookPayload_PlanReady) isHookPayload_Payload() {} - -func (*HookPayload_PostToolCall) isHookPayload_Payload() {} - -func (*HookPayload_PostApply) isHookPayload_Payload() {} - -func (*HookPayload_SessionEnd) isHookPayload_Payload() {} - -// SessionStartPayload fires once, at session creation. No field here is -// transform-mutable per agent-loop/hook-dispatch.md's per-point mutable- -// field table — session identity and startup parameters are fixed by the -// time this hook fires. -type SessionStartPayload struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The new session's id. MUST be set. Immutable. - SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - // The agent.hcl profile this session was created under. MUST be set. - // Immutable. - Profile string `protobuf:"bytes,2,opt,name=profile,proto3" json:"profile,omitempty"` - // The parent session's id, when this is a sub-agent session - // (agent-loop/subagents.md). Absent for a root session. Immutable. - ParentSessionId *string `protobuf:"bytes,3,opt,name=parent_session_id,json=parentSessionId,proto3,oneof" json:"parent_session_id,omitempty"` - // The session's working directory. MUST be set. Immutable. - WorkingDirectory string `protobuf:"bytes,4,opt,name=working_directory,json=workingDirectory,proto3" json:"working_directory,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SessionStartPayload) Reset() { - *x = SessionStartPayload{} - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SessionStartPayload) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SessionStartPayload) ProtoMessage() {} - -func (x *SessionStartPayload) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[1] - 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 SessionStartPayload.ProtoReflect.Descriptor instead. -func (*SessionStartPayload) Descriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{1} -} - -func (x *SessionStartPayload) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -func (x *SessionStartPayload) GetProfile() string { - if x != nil { - return x.Profile - } - return "" -} - -func (x *SessionStartPayload) GetParentSessionId() string { - if x != nil && x.ParentSessionId != nil { - return *x.ParentSessionId - } - return "" -} - -func (x *SessionStartPayload) GetWorkingDirectory() string { - if x != nil { - return x.WorkingDirectory - } - return "" -} - -// PreModelCallPayload fires immediately before a model provider's -// StreamCompletion is invoked. A transform subscriber MAY rewrite -// `messages` (e.g. redaction, injection of an additional instruction); it -// MUST NOT alter `model` — a hook subscriber does not get to silently -// reroute a turn to a different model than the one the turn algorithm -// already resolved. -type PreModelCallPayload struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The messages about to be sent to the model. Transform-mutable. - Messages []*v1.Message `protobuf:"bytes,1,rep,name=messages,proto3" json:"messages,omitempty"` - // The model this call targets. Immutable. - Model *v11.ModelRef `protobuf:"bytes,2,opt,name=model,proto3" json:"model,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PreModelCallPayload) Reset() { - *x = PreModelCallPayload{} - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PreModelCallPayload) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PreModelCallPayload) ProtoMessage() {} - -func (x *PreModelCallPayload) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[2] - 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 PreModelCallPayload.ProtoReflect.Descriptor instead. -func (*PreModelCallPayload) Descriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{2} -} - -func (x *PreModelCallPayload) GetMessages() []*v1.Message { - if x != nil { - return x.Messages - } - return nil -} - -func (x *PreModelCallPayload) GetModel() *v11.ModelRef { - if x != nil { - return x.Model - } - return nil -} - -// PostModelResponsePayload fires immediately after a model turn's -// canonical message has been assembled, before it is persisted -// (EVENT_KIND_MESSAGE). Primarily an observe-mode point (memory providers -// recording the turn, widgets displaying live usage) — no field here is -// documented transform-mutable in agent-loop/hook-dispatch.md's per-point -// table; a transform subscriber at this point MUST return the payload -// unchanged. -type PostModelResponsePayload struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The assembled assistant message. MUST be set. - Message *v1.Message `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` - // Which model-provider build produced `message`, for supersedes/replay - // attribution (architecture.md §Versioning & schema drift — - // "supersedes"). MUST be set. - Model *v12.ProducerRef `protobuf:"bytes,2,opt,name=model,proto3" json:"model,omitempty"` - // Token usage for the completion that produced `message`. MUST be set. - Usage *v11.Usage `protobuf:"bytes,3,opt,name=usage,proto3" json:"usage,omitempty"` - // The kernel-computed cost, in USD, of the completion that produced - // `message` (model/protocol.md#cost-computation — the provider never - // computes cost itself). MUST be set. - CostUsd float64 `protobuf:"fixed64,4,opt,name=cost_usd,json=costUsd,proto3" json:"cost_usd,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PostModelResponsePayload) Reset() { - *x = PostModelResponsePayload{} - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PostModelResponsePayload) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PostModelResponsePayload) ProtoMessage() {} - -func (x *PostModelResponsePayload) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[3] - 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 PostModelResponsePayload.ProtoReflect.Descriptor instead. -func (*PostModelResponsePayload) Descriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{3} -} - -func (x *PostModelResponsePayload) GetMessage() *v1.Message { - if x != nil { - return x.Message - } - return nil -} - -func (x *PostModelResponsePayload) GetModel() *v12.ProducerRef { - if x != nil { - return x.Model - } - return nil -} - -func (x *PostModelResponsePayload) GetUsage() *v11.Usage { - if x != nil { - return x.Usage - } - return nil -} - -func (x *PostModelResponsePayload) GetCostUsd() float64 { - if x != nil { - return x.CostUsd - } - return 0 -} - -// PreToolCallPayload fires immediately before an already-allowed plan -// item's tool call is applied. No field here is documented transform- -// mutable — a hook subscriber observes or vetoes an about-to-execute call, -// it does not get to silently rewrite its arguments; argument mutation is -// the plan/apply gate's own concern (agent-loop.md §5), not a general hook -// capability. -type PreToolCallPayload struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The call about to be applied. MUST be set. Immutable. - Call *v13.ToolCall `protobuf:"bytes,1,opt,name=call,proto3" json:"call,omitempty"` - // The originating plan item, carrying the policy decision that allowed - // this call through to apply. MUST be set. Immutable. - PlanItem *v14.PlanItem `protobuf:"bytes,2,opt,name=plan_item,json=planItem,proto3" json:"plan_item,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PreToolCallPayload) Reset() { - *x = PreToolCallPayload{} - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PreToolCallPayload) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PreToolCallPayload) ProtoMessage() {} - -func (x *PreToolCallPayload) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[4] - 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 PreToolCallPayload.ProtoReflect.Descriptor instead. -func (*PreToolCallPayload) Descriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{4} -} - -func (x *PreToolCallPayload) GetCall() *v13.ToolCall { - if x != nil { - return x.Call - } - return nil -} - -func (x *PreToolCallPayload) GetPlanItem() *v14.PlanItem { - if x != nil { - return x.PlanItem - } - return nil -} - -// PlanReadyPayload fires once a turn's Plan has been fully built, before -// the plan/apply gate dispatches it. This is the veto-bearing hook point: -// the kernel-privileged policy subscriber (architecture.md §Policy — -// first-party, not a plugin category) always runs here in HOOK_MODE_VETO. -// No field here is transform-mutable — a hook subscriber does not rewrite -// plan items; only the plan/apply gate itself and the policy veto affect -// what applies. -type PlanReadyPayload struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The fully-built plan awaiting policy evaluation. MUST be set. - // Immutable. - Plan *v14.Plan `protobuf:"bytes,1,opt,name=plan,proto3" json:"plan,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PlanReadyPayload) Reset() { - *x = PlanReadyPayload{} - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PlanReadyPayload) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PlanReadyPayload) ProtoMessage() {} - -func (x *PlanReadyPayload) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_hook_v1_hook_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 PlanReadyPayload.ProtoReflect.Descriptor instead. -func (*PlanReadyPayload) Descriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{5} -} - -func (x *PlanReadyPayload) GetPlan() *v14.Plan { - if x != nil { - return x.Plan - } - return nil -} - -// PostToolCallPayload fires immediately after a plan item's tool call -// reaches a terminal outcome. Observe-only in practice — no field here is -// documented transform-mutable. -type PostToolCallPayload struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The call that completed. MUST be set. Immutable. - Call *v13.ToolCall `protobuf:"bytes,1,opt,name=call,proto3" json:"call,omitempty"` - // Types that are valid to be assigned to Outcome: - // - // *PostToolCallPayload_Result - // *PostToolCallPayload_Error - Outcome isPostToolCallPayload_Outcome `protobuf_oneof:"outcome"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PostToolCallPayload) Reset() { - *x = PostToolCallPayload{} - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PostToolCallPayload) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PostToolCallPayload) ProtoMessage() {} - -func (x *PostToolCallPayload) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_hook_v1_hook_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 PostToolCallPayload.ProtoReflect.Descriptor instead. -func (*PostToolCallPayload) Descriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{6} -} - -func (x *PostToolCallPayload) GetCall() *v13.ToolCall { - if x != nil { - return x.Call - } - return nil -} - -func (x *PostToolCallPayload) GetOutcome() isPostToolCallPayload_Outcome { - if x != nil { - return x.Outcome - } - return nil -} - -func (x *PostToolCallPayload) GetResult() *v13.ToolResult { - if x != nil { - if x, ok := x.Outcome.(*PostToolCallPayload_Result); ok { - return x.Result - } - } - return nil -} - -func (x *PostToolCallPayload) GetError() *v13.ToolError { - if x != nil { - if x, ok := x.Outcome.(*PostToolCallPayload_Error); ok { - return x.Error - } - } - return nil -} - -type isPostToolCallPayload_Outcome interface { - isPostToolCallPayload_Outcome() -} - -type PostToolCallPayload_Result struct { - // The call's successful terminal result. - Result *v13.ToolResult `protobuf:"bytes,2,opt,name=result,proto3,oneof"` -} - -type PostToolCallPayload_Error struct { - // The call's failed terminal result. - Error *v13.ToolError `protobuf:"bytes,3,opt,name=error,proto3,oneof"` -} - -func (*PostToolCallPayload_Result) isPostToolCallPayload_Outcome() {} - -func (*PostToolCallPayload_Error) isPostToolCallPayload_Outcome() {} - -// PostApplyPayload fires once a turn's whole Plan has finished applying — -// every item has reached a terminal ApplyOutcome -// (pluggableharness.plan.v1.ApplyResult.ApplyOutcome). Reuses -// plan.v1.ApplyResult rather than defining its own per-item outcome shape, -// so the post-apply hook's subject and the EVENT_KIND_APPLY event (the -// forthcoming event.v1 package) are the exact same message. Observe-only — -// applying has already happened by the time this fires, so there is -// nothing left to transform. -type PostApplyPayload struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The turn's per-item apply outcomes. MUST be set. - Apply *v14.ApplyResult `protobuf:"bytes,1,opt,name=apply,proto3" json:"apply,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PostApplyPayload) Reset() { - *x = PostApplyPayload{} - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PostApplyPayload) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PostApplyPayload) ProtoMessage() {} - -func (x *PostApplyPayload) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[7] - 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 PostApplyPayload.ProtoReflect.Descriptor instead. -func (*PostApplyPayload) Descriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{7} -} - -func (x *PostApplyPayload) GetApply() *v14.ApplyResult { - if x != nil { - return x.Apply - } - return nil -} - -// SessionEndPayload fires once, when a session reaches a terminal -// SessionStatus. No field here is transform-mutable — a session's outcome -// is already final by the time this fires. -type SessionEndPayload struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The session that ended. MUST be set. Immutable. - SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - // The session's terminal status. MUST be set (never - // SESSION_STATUS_RUNNING). Immutable. - Status v15.SessionStatus `protobuf:"varint,2,opt,name=status,proto3,enum=pluggableharness.session.v1.SessionStatus" json:"status,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SessionEndPayload) Reset() { - *x = SessionEndPayload{} - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SessionEndPayload) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SessionEndPayload) ProtoMessage() {} - -func (x *SessionEndPayload) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[8] - 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 SessionEndPayload.ProtoReflect.Descriptor instead. -func (*SessionEndPayload) Descriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{8} -} - -func (x *SessionEndPayload) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -func (x *SessionEndPayload) GetStatus() v15.SessionStatus { - if x != nil { - return x.Status - } - return v15.SessionStatus(0) -} - -// DispatchHookRequest is one hook-point firing delivered to one -// subscriber. -type DispatchHookRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The payload for this firing. MUST be set; the set oneof variant - // implicitly identifies the hook point. - Payload *HookPayload `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` - // The operator-configured mode this subscription runs under - // (agent.hcl's hook{} block). MUST be set — tells the subscriber which - // of DispatchHookResponse's three outcome shapes is expected back. - Mode HookMode `protobuf:"varint,2,opt,name=mode,proto3,enum=pluggableharness.hook.v1.HookMode" json:"mode,omitempty"` - // Disambiguates a plugin declaring more than one hook{} block at the - // same HookPoint. Absent when a plugin has exactly one subscription at - // this point. - SubscriptionId *string `protobuf:"bytes,3,opt,name=subscription_id,json=subscriptionId,proto3,oneof" json:"subscription_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DispatchHookRequest) Reset() { - *x = DispatchHookRequest{} - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DispatchHookRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DispatchHookRequest) ProtoMessage() {} - -func (x *DispatchHookRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[9] - 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 DispatchHookRequest.ProtoReflect.Descriptor instead. -func (*DispatchHookRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{9} -} - -func (x *DispatchHookRequest) GetPayload() *HookPayload { - if x != nil { - return x.Payload - } - return nil -} - -func (x *DispatchHookRequest) GetMode() HookMode { - if x != nil { - return x.Mode - } - return HookMode_HOOK_MODE_UNSPECIFIED -} - -func (x *DispatchHookRequest) GetSubscriptionId() string { - if x != nil && x.SubscriptionId != nil { - return *x.SubscriptionId - } - return "" -} - -// DispatchHookResponse carries a subscriber's outcome, shaped by the -// HookMode the request declared. The kernel MUST reject (surfacing a -// hook_error, per agent-loop/hook-dispatch.md#subscriber-error-handling) -// a response whose oneof variant doesn't match the request's declared -// mode — HOOK_ERROR_CATEGORY_INVALID_RESPONSE. -type DispatchHookResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Types that are valid to be assigned to Outcome: - // - // *DispatchHookResponse_Observe - // *DispatchHookResponse_Transform - // *DispatchHookResponse_Veto - Outcome isDispatchHookResponse_Outcome `protobuf_oneof:"outcome"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DispatchHookResponse) Reset() { - *x = DispatchHookResponse{} - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DispatchHookResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DispatchHookResponse) ProtoMessage() {} - -func (x *DispatchHookResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[10] - 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 DispatchHookResponse.ProtoReflect.Descriptor instead. -func (*DispatchHookResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{10} -} - -func (x *DispatchHookResponse) GetOutcome() isDispatchHookResponse_Outcome { - if x != nil { - return x.Outcome - } - return nil -} - -func (x *DispatchHookResponse) GetObserve() *DispatchHookResponse_ObserveAck { - if x != nil { - if x, ok := x.Outcome.(*DispatchHookResponse_Observe); ok { - return x.Observe - } - } - return nil -} - -func (x *DispatchHookResponse) GetTransform() *DispatchHookResponse_TransformResult { - if x != nil { - if x, ok := x.Outcome.(*DispatchHookResponse_Transform); ok { - return x.Transform - } - } - return nil -} - -func (x *DispatchHookResponse) GetVeto() *DispatchHookResponse_VetoResult { - if x != nil { - if x, ok := x.Outcome.(*DispatchHookResponse_Veto); ok { - return x.Veto - } - } - return nil -} - -type isDispatchHookResponse_Outcome interface { - isDispatchHookResponse_Outcome() -} - -type DispatchHookResponse_Observe struct { - // The subscriber's acknowledgment, for a HOOK_MODE_OBSERVE request. - Observe *DispatchHookResponse_ObserveAck `protobuf:"bytes,1,opt,name=observe,proto3,oneof"` -} - -type DispatchHookResponse_Transform struct { - // The subscriber's modified payload, for a HOOK_MODE_TRANSFORM - // request. - Transform *DispatchHookResponse_TransformResult `protobuf:"bytes,2,opt,name=transform,proto3,oneof"` -} - -type DispatchHookResponse_Veto struct { - // The subscriber's allow/deny verdict, for a HOOK_MODE_VETO request. - Veto *DispatchHookResponse_VetoResult `protobuf:"bytes,3,opt,name=veto,proto3,oneof"` -} - -func (*DispatchHookResponse_Observe) isDispatchHookResponse_Outcome() {} - -func (*DispatchHookResponse_Transform) isDispatchHookResponse_Outcome() {} - -func (*DispatchHookResponse_Veto) isDispatchHookResponse_Outcome() {} - -// HookError is the structured detail describing one failed hook dispatch. -// Also the payload shape the kernel-synthesized EVENT_KIND_HOOK_ERROR -// event carries (kernel.v1.EventKind, state-backend.md §The kind enum) — -// the forthcoming event.v1 package's HookErrorEvent wraps this same -// message rather than redefining it. -type HookError struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Which hook point the failing dispatch was for. MUST be set. - Point v12.HookPoint `protobuf:"varint,1,opt,name=point,proto3,enum=pluggableharness.common.v1.HookPoint" json:"point,omitempty"` - // Which plugin build the failing subscriber was. MUST be set. - Subscriber *v12.ProducerRef `protobuf:"bytes,2,opt,name=subscriber,proto3" json:"subscriber,omitempty"` - // The HookMode the failing subscription was declared under. MUST be - // set. - Mode HookMode `protobuf:"varint,3,opt,name=mode,proto3,enum=pluggableharness.hook.v1.HookMode" json:"mode,omitempty"` - // Which category of failure this is. MUST be set. - Category HookErrorCategory `protobuf:"varint,4,opt,name=category,proto3,enum=pluggableharness.hook.v1.HookErrorCategory" json:"category,omitempty"` - // Human-readable detail, e.g. the raw subscriber error message. - Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *HookError) Reset() { - *x = HookError{} - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *HookError) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*HookError) ProtoMessage() {} - -func (x *HookError) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[11] - 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 HookError.ProtoReflect.Descriptor instead. -func (*HookError) Descriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{11} -} - -func (x *HookError) GetPoint() v12.HookPoint { - if x != nil { - return x.Point - } - return v12.HookPoint(0) -} - -func (x *HookError) GetSubscriber() *v12.ProducerRef { - if x != nil { - return x.Subscriber - } - return nil -} - -func (x *HookError) GetMode() HookMode { - if x != nil { - return x.Mode - } - return HookMode_HOOK_MODE_UNSPECIFIED -} - -func (x *HookError) GetCategory() HookErrorCategory { - if x != nil { - return x.Category - } - return HookErrorCategory_HOOK_ERROR_CATEGORY_UNSPECIFIED -} - -func (x *HookError) GetMessage() string { - if x != nil { - return x.Message - } - return "" -} - -// ObserveAck is empty. The kernel discards it (and any payload an -// observe subscriber mistakenly returns) unconditionally — observe mode -// can never alter the payload, per -// agent-loop/hook-dispatch.md#subscriber-error-handling. -type DispatchHookResponse_ObserveAck struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DispatchHookResponse_ObserveAck) Reset() { - *x = DispatchHookResponse_ObserveAck{} - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DispatchHookResponse_ObserveAck) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DispatchHookResponse_ObserveAck) ProtoMessage() {} - -func (x *DispatchHookResponse_ObserveAck) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[12] - 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 DispatchHookResponse_ObserveAck.ProtoReflect.Descriptor instead. -func (*DispatchHookResponse_ObserveAck) Descriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{10, 0} -} - -// TransformResult carries a transform subscriber's modified payload. -type DispatchHookResponse_TransformResult struct { - state protoimpl.MessageState `protogen:"open.v1"` - // MUST be the same oneof variant as the request's payload. MUST only - // mutate the fields this hook point documents as transform-mutable - // (see each *Payload message's own comment) — the kernel MUST reject - // a response that changes an immutable field or the variant itself, - // per agent-loop/hook-dispatch.md#subscriber-error-handling. - Payload *HookPayload `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DispatchHookResponse_TransformResult) Reset() { - *x = DispatchHookResponse_TransformResult{} - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DispatchHookResponse_TransformResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DispatchHookResponse_TransformResult) ProtoMessage() {} - -func (x *DispatchHookResponse_TransformResult) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[13] - 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 DispatchHookResponse_TransformResult.ProtoReflect.Descriptor instead. -func (*DispatchHookResponse_TransformResult) Descriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{10, 1} -} - -func (x *DispatchHookResponse_TransformResult) GetPayload() *HookPayload { - if x != nil { - return x.Payload - } - return nil -} - -// VetoResult carries a veto subscriber's allow/deny verdict. -type DispatchHookResponse_VetoResult struct { - state protoimpl.MessageState `protogen:"open.v1"` - // MUST be set. HOOK_DECISION_UNSPECIFIED is treated as an invalid - // response (HOOK_ERROR_CATEGORY_INVALID_RESPONSE), not as an implicit - // deny — the fail-closed behavior for a genuinely absent/erroring - // response is handled at the gRPC-status level, not by this enum's - // zero value. - Decision HookDecision `protobuf:"varint,1,opt,name=decision,proto3,enum=pluggableharness.hook.v1.HookDecision" json:"decision,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DispatchHookResponse_VetoResult) Reset() { - *x = DispatchHookResponse_VetoResult{} - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DispatchHookResponse_VetoResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DispatchHookResponse_VetoResult) ProtoMessage() {} - -func (x *DispatchHookResponse_VetoResult) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_hook_v1_hook_proto_msgTypes[14] - 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 DispatchHookResponse_VetoResult.ProtoReflect.Descriptor instead. -func (*DispatchHookResponse_VetoResult) Descriptor() ([]byte, []int) { - return file_pluggableharness_hook_v1_hook_proto_rawDescGZIP(), []int{10, 2} -} - -func (x *DispatchHookResponse_VetoResult) GetDecision() HookDecision { - if x != nil { - return x.Decision - } - return HookDecision_HOOK_DECISION_UNSPECIFIED -} - -var File_pluggableharness_hook_v1_hook_proto protoreflect.FileDescriptor - -const file_pluggableharness_hook_v1_hook_proto_rawDesc = "" + - "\n" + - "#pluggableharness/hook/v1/hook.proto\x12\x18pluggableharness.hook.v1\x1a'pluggableharness/common/v1/common.proto\x1a)pluggableharness/content/v1/content.proto\x1a%pluggableharness/model/v1/model.proto\x1a#pluggableharness/plan/v1/plan.proto\x1a)pluggableharness/session/v1/session.proto\x1a#pluggableharness/tool/v1/tool.proto\"\xc0\x05\n" + - "\vHookPayload\x12T\n" + - "\rsession_start\x18\x01 \x01(\v2-.pluggableharness.hook.v1.SessionStartPayloadH\x00R\fsessionStart\x12U\n" + - "\x0epre_model_call\x18\x02 \x01(\v2-.pluggableharness.hook.v1.PreModelCallPayloadH\x00R\fpreModelCall\x12d\n" + - "\x13post_model_response\x18\x03 \x01(\v22.pluggableharness.hook.v1.PostModelResponsePayloadH\x00R\x11postModelResponse\x12R\n" + - "\rpre_tool_call\x18\x04 \x01(\v2,.pluggableharness.hook.v1.PreToolCallPayloadH\x00R\vpreToolCall\x12K\n" + - "\n" + - "plan_ready\x18\x05 \x01(\v2*.pluggableharness.hook.v1.PlanReadyPayloadH\x00R\tplanReady\x12U\n" + - "\x0epost_tool_call\x18\x06 \x01(\v2-.pluggableharness.hook.v1.PostToolCallPayloadH\x00R\fpostToolCall\x12K\n" + - "\n" + - "post_apply\x18\a \x01(\v2*.pluggableharness.hook.v1.PostApplyPayloadH\x00R\tpostApply\x12N\n" + - "\vsession_end\x18\b \x01(\v2+.pluggableharness.hook.v1.SessionEndPayloadH\x00R\n" + - "sessionEndB\t\n" + - "\apayload\"\xc2\x01\n" + - "\x13SessionStartPayload\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\x12\x18\n" + - "\aprofile\x18\x02 \x01(\tR\aprofile\x12/\n" + - "\x11parent_session_id\x18\x03 \x01(\tH\x00R\x0fparentSessionId\x88\x01\x01\x12+\n" + - "\x11working_directory\x18\x04 \x01(\tR\x10workingDirectoryB\x14\n" + - "\x12_parent_session_id\"\x92\x01\n" + - "\x13PreModelCallPayload\x12@\n" + - "\bmessages\x18\x01 \x03(\v2$.pluggableharness.content.v1.MessageR\bmessages\x129\n" + - "\x05model\x18\x02 \x01(\v2#.pluggableharness.model.v1.ModelRefR\x05model\"\xec\x01\n" + - "\x18PostModelResponsePayload\x12>\n" + - "\amessage\x18\x01 \x01(\v2$.pluggableharness.content.v1.MessageR\amessage\x12=\n" + - "\x05model\x18\x02 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\x05model\x126\n" + - "\x05usage\x18\x03 \x01(\v2 .pluggableharness.model.v1.UsageR\x05usage\x12\x19\n" + - "\bcost_usd\x18\x04 \x01(\x01R\acostUsd\"\x8d\x01\n" + - "\x12PreToolCallPayload\x126\n" + - "\x04call\x18\x01 \x01(\v2\".pluggableharness.tool.v1.ToolCallR\x04call\x12?\n" + - "\tplan_item\x18\x02 \x01(\v2\".pluggableharness.plan.v1.PlanItemR\bplanItem\"F\n" + - "\x10PlanReadyPayload\x122\n" + - "\x04plan\x18\x01 \x01(\v2\x1e.pluggableharness.plan.v1.PlanR\x04plan\"\xd5\x01\n" + - "\x13PostToolCallPayload\x126\n" + - "\x04call\x18\x01 \x01(\v2\".pluggableharness.tool.v1.ToolCallR\x04call\x12>\n" + - "\x06result\x18\x02 \x01(\v2$.pluggableharness.tool.v1.ToolResultH\x00R\x06result\x12;\n" + - "\x05error\x18\x03 \x01(\v2#.pluggableharness.tool.v1.ToolErrorH\x00R\x05errorB\t\n" + - "\aoutcome\"O\n" + - "\x10PostApplyPayload\x12;\n" + - "\x05apply\x18\x01 \x01(\v2%.pluggableharness.plan.v1.ApplyResultR\x05apply\"v\n" + - "\x11SessionEndPayload\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\x12B\n" + - "\x06status\x18\x02 \x01(\x0e2*.pluggableharness.session.v1.SessionStatusR\x06status\"\xd0\x01\n" + - "\x13DispatchHookRequest\x12?\n" + - "\apayload\x18\x01 \x01(\v2%.pluggableharness.hook.v1.HookPayloadR\apayload\x126\n" + - "\x04mode\x18\x02 \x01(\x0e2\".pluggableharness.hook.v1.HookModeR\x04mode\x12,\n" + - "\x0fsubscription_id\x18\x03 \x01(\tH\x00R\x0esubscriptionId\x88\x01\x01B\x12\n" + - "\x10_subscription_id\"\xdd\x03\n" + - "\x14DispatchHookResponse\x12U\n" + - "\aobserve\x18\x01 \x01(\v29.pluggableharness.hook.v1.DispatchHookResponse.ObserveAckH\x00R\aobserve\x12^\n" + - "\ttransform\x18\x02 \x01(\v2>.pluggableharness.hook.v1.DispatchHookResponse.TransformResultH\x00R\ttransform\x12O\n" + - "\x04veto\x18\x03 \x01(\v29.pluggableharness.hook.v1.DispatchHookResponse.VetoResultH\x00R\x04veto\x1a\f\n" + - "\n" + - "ObserveAck\x1aR\n" + - "\x0fTransformResult\x12?\n" + - "\apayload\x18\x01 \x01(\v2%.pluggableharness.hook.v1.HookPayloadR\apayload\x1aP\n" + - "\n" + - "VetoResult\x12B\n" + - "\bdecision\x18\x01 \x01(\x0e2&.pluggableharness.hook.v1.HookDecisionR\bdecisionB\t\n" + - "\aoutcome\"\xac\x02\n" + - "\tHookError\x12;\n" + - "\x05point\x18\x01 \x01(\x0e2%.pluggableharness.common.v1.HookPointR\x05point\x12G\n" + - "\n" + - "subscriber\x18\x02 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\n" + - "subscriber\x126\n" + - "\x04mode\x18\x03 \x01(\x0e2\".pluggableharness.hook.v1.HookModeR\x04mode\x12G\n" + - "\bcategory\x18\x04 \x01(\x0e2+.pluggableharness.hook.v1.HookErrorCategoryR\bcategory\x12\x18\n" + - "\amessage\x18\x05 \x01(\tR\amessage*i\n" + - "\bHookMode\x12\x19\n" + - "\x15HOOK_MODE_UNSPECIFIED\x10\x00\x12\x15\n" + - "\x11HOOK_MODE_OBSERVE\x10\x01\x12\x17\n" + - "\x13HOOK_MODE_TRANSFORM\x10\x02\x12\x12\n" + - "\x0eHOOK_MODE_VETO\x10\x03*^\n" + - "\fHookDecision\x12\x1d\n" + - "\x19HOOK_DECISION_UNSPECIFIED\x10\x00\x12\x17\n" + - "\x13HOOK_DECISION_ALLOW\x10\x01\x12\x16\n" + - "\x12HOOK_DECISION_DENY\x10\x02*\x9c\x02\n" + - "\x11HookErrorCategory\x12#\n" + - "\x1fHOOK_ERROR_CATEGORY_UNSPECIFIED\x10\x00\x12(\n" + - "$HOOK_ERROR_CATEGORY_TRANSFORM_FAILED\x10\x01\x12#\n" + - "\x1fHOOK_ERROR_CATEGORY_VETO_FAILED\x10\x02\x12\x1f\n" + - "\x1bHOOK_ERROR_CATEGORY_TIMEOUT\x10\x03\x12(\n" + - "$HOOK_ERROR_CATEGORY_INVALID_RESPONSE\x10\x04\x12'\n" + - "#HOOK_ERROR_CATEGORY_PROCESS_CRASHED\x10\x05\x12\x1f\n" + - "\x1bHOOK_ERROR_CATEGORY_UNKNOWN\x10\x062\x86\x01\n" + - "\x15HookSubscriberService\x12m\n" + - "\fDispatchHook\x12-.pluggableharness.hook.v1.DispatchHookRequest\x1a..pluggableharness.hook.v1.DispatchHookResponseB pluggableharness.hook.v1.SessionStartPayload - 5, // 1: pluggableharness.hook.v1.HookPayload.pre_model_call:type_name -> pluggableharness.hook.v1.PreModelCallPayload - 6, // 2: pluggableharness.hook.v1.HookPayload.post_model_response:type_name -> pluggableharness.hook.v1.PostModelResponsePayload - 7, // 3: pluggableharness.hook.v1.HookPayload.pre_tool_call:type_name -> pluggableharness.hook.v1.PreToolCallPayload - 8, // 4: pluggableharness.hook.v1.HookPayload.plan_ready:type_name -> pluggableharness.hook.v1.PlanReadyPayload - 9, // 5: pluggableharness.hook.v1.HookPayload.post_tool_call:type_name -> pluggableharness.hook.v1.PostToolCallPayload - 10, // 6: pluggableharness.hook.v1.HookPayload.post_apply:type_name -> pluggableharness.hook.v1.PostApplyPayload - 11, // 7: pluggableharness.hook.v1.HookPayload.session_end:type_name -> pluggableharness.hook.v1.SessionEndPayload - 18, // 8: pluggableharness.hook.v1.PreModelCallPayload.messages:type_name -> pluggableharness.content.v1.Message - 19, // 9: pluggableharness.hook.v1.PreModelCallPayload.model:type_name -> pluggableharness.model.v1.ModelRef - 18, // 10: pluggableharness.hook.v1.PostModelResponsePayload.message:type_name -> pluggableharness.content.v1.Message - 20, // 11: pluggableharness.hook.v1.PostModelResponsePayload.model:type_name -> pluggableharness.common.v1.ProducerRef - 21, // 12: pluggableharness.hook.v1.PostModelResponsePayload.usage:type_name -> pluggableharness.model.v1.Usage - 22, // 13: pluggableharness.hook.v1.PreToolCallPayload.call:type_name -> pluggableharness.tool.v1.ToolCall - 23, // 14: pluggableharness.hook.v1.PreToolCallPayload.plan_item:type_name -> pluggableharness.plan.v1.PlanItem - 24, // 15: pluggableharness.hook.v1.PlanReadyPayload.plan:type_name -> pluggableharness.plan.v1.Plan - 22, // 16: pluggableharness.hook.v1.PostToolCallPayload.call:type_name -> pluggableharness.tool.v1.ToolCall - 25, // 17: pluggableharness.hook.v1.PostToolCallPayload.result:type_name -> pluggableharness.tool.v1.ToolResult - 26, // 18: pluggableharness.hook.v1.PostToolCallPayload.error:type_name -> pluggableharness.tool.v1.ToolError - 27, // 19: pluggableharness.hook.v1.PostApplyPayload.apply:type_name -> pluggableharness.plan.v1.ApplyResult - 28, // 20: pluggableharness.hook.v1.SessionEndPayload.status:type_name -> pluggableharness.session.v1.SessionStatus - 3, // 21: pluggableharness.hook.v1.DispatchHookRequest.payload:type_name -> pluggableharness.hook.v1.HookPayload - 0, // 22: pluggableharness.hook.v1.DispatchHookRequest.mode:type_name -> pluggableharness.hook.v1.HookMode - 15, // 23: pluggableharness.hook.v1.DispatchHookResponse.observe:type_name -> pluggableharness.hook.v1.DispatchHookResponse.ObserveAck - 16, // 24: pluggableharness.hook.v1.DispatchHookResponse.transform:type_name -> pluggableharness.hook.v1.DispatchHookResponse.TransformResult - 17, // 25: pluggableharness.hook.v1.DispatchHookResponse.veto:type_name -> pluggableharness.hook.v1.DispatchHookResponse.VetoResult - 29, // 26: pluggableharness.hook.v1.HookError.point:type_name -> pluggableharness.common.v1.HookPoint - 20, // 27: pluggableharness.hook.v1.HookError.subscriber:type_name -> pluggableharness.common.v1.ProducerRef - 0, // 28: pluggableharness.hook.v1.HookError.mode:type_name -> pluggableharness.hook.v1.HookMode - 2, // 29: pluggableharness.hook.v1.HookError.category:type_name -> pluggableharness.hook.v1.HookErrorCategory - 3, // 30: pluggableharness.hook.v1.DispatchHookResponse.TransformResult.payload:type_name -> pluggableharness.hook.v1.HookPayload - 1, // 31: pluggableharness.hook.v1.DispatchHookResponse.VetoResult.decision:type_name -> pluggableharness.hook.v1.HookDecision - 12, // 32: pluggableharness.hook.v1.HookSubscriberService.DispatchHook:input_type -> pluggableharness.hook.v1.DispatchHookRequest - 13, // 33: pluggableharness.hook.v1.HookSubscriberService.DispatchHook:output_type -> pluggableharness.hook.v1.DispatchHookResponse - 33, // [33:34] is the sub-list for method output_type - 32, // [32:33] is the sub-list for method input_type - 32, // [32:32] is the sub-list for extension type_name - 32, // [32:32] is the sub-list for extension extendee - 0, // [0:32] is the sub-list for field type_name -} - -func init() { file_pluggableharness_hook_v1_hook_proto_init() } -func file_pluggableharness_hook_v1_hook_proto_init() { - if File_pluggableharness_hook_v1_hook_proto != nil { - return - } - file_pluggableharness_hook_v1_hook_proto_msgTypes[0].OneofWrappers = []any{ - (*HookPayload_SessionStart)(nil), - (*HookPayload_PreModelCall)(nil), - (*HookPayload_PostModelResponse)(nil), - (*HookPayload_PreToolCall)(nil), - (*HookPayload_PlanReady)(nil), - (*HookPayload_PostToolCall)(nil), - (*HookPayload_PostApply)(nil), - (*HookPayload_SessionEnd)(nil), - } - file_pluggableharness_hook_v1_hook_proto_msgTypes[1].OneofWrappers = []any{} - file_pluggableharness_hook_v1_hook_proto_msgTypes[6].OneofWrappers = []any{ - (*PostToolCallPayload_Result)(nil), - (*PostToolCallPayload_Error)(nil), - } - file_pluggableharness_hook_v1_hook_proto_msgTypes[9].OneofWrappers = []any{} - file_pluggableharness_hook_v1_hook_proto_msgTypes[10].OneofWrappers = []any{ - (*DispatchHookResponse_Observe)(nil), - (*DispatchHookResponse_Transform)(nil), - (*DispatchHookResponse_Veto)(nil), - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_hook_v1_hook_proto_rawDesc), len(file_pluggableharness_hook_v1_hook_proto_rawDesc)), - NumEnums: 3, - NumMessages: 15, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_pluggableharness_hook_v1_hook_proto_goTypes, - DependencyIndexes: file_pluggableharness_hook_v1_hook_proto_depIdxs, - EnumInfos: file_pluggableharness_hook_v1_hook_proto_enumTypes, - MessageInfos: file_pluggableharness_hook_v1_hook_proto_msgTypes, - }.Build() - File_pluggableharness_hook_v1_hook_proto = out.File - file_pluggableharness_hook_v1_hook_proto_goTypes = nil - file_pluggableharness_hook_v1_hook_proto_depIdxs = nil -} diff --git a/pkg/hook/proto/v1/rpc_request.pb.go b/pkg/hook/proto/v1/rpc_request.pb.go new file mode 100644 index 0000000..395479a --- /dev/null +++ b/pkg/hook/proto/v1/rpc_request.pb.go @@ -0,0 +1,158 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/hook/v1/rpc_request.proto + +package hookv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// DispatchHookRequest is one hook-point firing delivered to one +// subscriber. +type DispatchHookRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The payload for this firing. MUST be set; the set oneof variant + // implicitly identifies the hook point. + Payload *HookPayload `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` + // The operator-configured mode this subscription runs under + // (agent.hcl's hook{} block). MUST be set — tells the subscriber which + // of DispatchHookResponse's three outcome shapes is expected back. + Mode HookMode `protobuf:"varint,2,opt,name=mode,proto3,enum=pluggableharness.hook.v1.HookMode" json:"mode,omitempty"` + // Disambiguates a plugin declaring more than one hook{} block at the + // same HookPoint. Absent when a plugin has exactly one subscription at + // this point. + SubscriptionId *string `protobuf:"bytes,3,opt,name=subscription_id,json=subscriptionId,proto3,oneof" json:"subscription_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DispatchHookRequest) Reset() { + *x = DispatchHookRequest{} + mi := &file_pluggableharness_hook_v1_rpc_request_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DispatchHookRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DispatchHookRequest) ProtoMessage() {} + +func (x *DispatchHookRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_hook_v1_rpc_request_proto_msgTypes[0] + 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 DispatchHookRequest.ProtoReflect.Descriptor instead. +func (*DispatchHookRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_rpc_request_proto_rawDescGZIP(), []int{0} +} + +func (x *DispatchHookRequest) GetPayload() *HookPayload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *DispatchHookRequest) GetMode() HookMode { + if x != nil { + return x.Mode + } + return HookMode_HOOK_MODE_UNSPECIFIED +} + +func (x *DispatchHookRequest) GetSubscriptionId() string { + if x != nil && x.SubscriptionId != nil { + return *x.SubscriptionId + } + return "" +} + +var File_pluggableharness_hook_v1_rpc_request_proto protoreflect.FileDescriptor + +const file_pluggableharness_hook_v1_rpc_request_proto_rawDesc = "" + + "\n" + + "*pluggableharness/hook/v1/rpc_request.proto\x12\x18pluggableharness.hook.v1\x1a%pluggableharness/hook/v1/events.proto\x1a$pluggableharness/hook/v1/types.proto\"\xd0\x01\n" + + "\x13DispatchHookRequest\x12?\n" + + "\apayload\x18\x01 \x01(\v2%.pluggableharness.hook.v1.HookPayloadR\apayload\x126\n" + + "\x04mode\x18\x02 \x01(\x0e2\".pluggableharness.hook.v1.HookModeR\x04mode\x12,\n" + + "\x0fsubscription_id\x18\x03 \x01(\tH\x00R\x0esubscriptionId\x88\x01\x01B\x12\n" + + "\x10_subscription_idB pluggableharness.hook.v1.HookPayload + 2, // 1: pluggableharness.hook.v1.DispatchHookRequest.mode:type_name -> pluggableharness.hook.v1.HookMode + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_pluggableharness_hook_v1_rpc_request_proto_init() } +func file_pluggableharness_hook_v1_rpc_request_proto_init() { + if File_pluggableharness_hook_v1_rpc_request_proto != nil { + return + } + file_pluggableharness_hook_v1_events_proto_init() + file_pluggableharness_hook_v1_types_proto_init() + file_pluggableharness_hook_v1_rpc_request_proto_msgTypes[0].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_hook_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_hook_v1_rpc_request_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_hook_v1_rpc_request_proto_goTypes, + DependencyIndexes: file_pluggableharness_hook_v1_rpc_request_proto_depIdxs, + MessageInfos: file_pluggableharness_hook_v1_rpc_request_proto_msgTypes, + }.Build() + File_pluggableharness_hook_v1_rpc_request_proto = out.File + file_pluggableharness_hook_v1_rpc_request_proto_goTypes = nil + file_pluggableharness_hook_v1_rpc_request_proto_depIdxs = nil +} diff --git a/pkg/hook/proto/v1/rpc_response.pb.go b/pkg/hook/proto/v1/rpc_response.pb.go new file mode 100644 index 0000000..027a745 --- /dev/null +++ b/pkg/hook/proto/v1/rpc_response.pb.go @@ -0,0 +1,352 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/hook/v1/rpc_response.proto + +package hookv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// DispatchHookResponse carries a subscriber's outcome, shaped by the +// HookMode the request declared. The kernel MUST reject (surfacing a +// hook_error, per agent-loop/hook-dispatch.md#subscriber-error-handling) +// a response whose oneof variant doesn't match the request's declared +// mode — HOOK_ERROR_CATEGORY_INVALID_RESPONSE. +type DispatchHookResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Outcome: + // + // *DispatchHookResponse_Observe + // *DispatchHookResponse_Transform + // *DispatchHookResponse_Veto + Outcome isDispatchHookResponse_Outcome `protobuf_oneof:"outcome"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DispatchHookResponse) Reset() { + *x = DispatchHookResponse{} + mi := &file_pluggableharness_hook_v1_rpc_response_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DispatchHookResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DispatchHookResponse) ProtoMessage() {} + +func (x *DispatchHookResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_hook_v1_rpc_response_proto_msgTypes[0] + 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 DispatchHookResponse.ProtoReflect.Descriptor instead. +func (*DispatchHookResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_rpc_response_proto_rawDescGZIP(), []int{0} +} + +func (x *DispatchHookResponse) GetOutcome() isDispatchHookResponse_Outcome { + if x != nil { + return x.Outcome + } + return nil +} + +func (x *DispatchHookResponse) GetObserve() *DispatchHookResponse_ObserveAck { + if x != nil { + if x, ok := x.Outcome.(*DispatchHookResponse_Observe); ok { + return x.Observe + } + } + return nil +} + +func (x *DispatchHookResponse) GetTransform() *DispatchHookResponse_TransformResult { + if x != nil { + if x, ok := x.Outcome.(*DispatchHookResponse_Transform); ok { + return x.Transform + } + } + return nil +} + +func (x *DispatchHookResponse) GetVeto() *DispatchHookResponse_VetoResult { + if x != nil { + if x, ok := x.Outcome.(*DispatchHookResponse_Veto); ok { + return x.Veto + } + } + return nil +} + +type isDispatchHookResponse_Outcome interface { + isDispatchHookResponse_Outcome() +} + +type DispatchHookResponse_Observe struct { + // The subscriber's acknowledgment, for a HOOK_MODE_OBSERVE request. + Observe *DispatchHookResponse_ObserveAck `protobuf:"bytes,1,opt,name=observe,proto3,oneof"` +} + +type DispatchHookResponse_Transform struct { + // The subscriber's modified payload, for a HOOK_MODE_TRANSFORM + // request. + Transform *DispatchHookResponse_TransformResult `protobuf:"bytes,2,opt,name=transform,proto3,oneof"` +} + +type DispatchHookResponse_Veto struct { + // The subscriber's allow/deny verdict, for a HOOK_MODE_VETO request. + Veto *DispatchHookResponse_VetoResult `protobuf:"bytes,3,opt,name=veto,proto3,oneof"` +} + +func (*DispatchHookResponse_Observe) isDispatchHookResponse_Outcome() {} + +func (*DispatchHookResponse_Transform) isDispatchHookResponse_Outcome() {} + +func (*DispatchHookResponse_Veto) isDispatchHookResponse_Outcome() {} + +// ObserveAck is empty. The kernel discards it (and any payload an +// observe subscriber mistakenly returns) unconditionally — observe mode +// can never alter the payload, per +// agent-loop/hook-dispatch.md#subscriber-error-handling. +type DispatchHookResponse_ObserveAck struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DispatchHookResponse_ObserveAck) Reset() { + *x = DispatchHookResponse_ObserveAck{} + mi := &file_pluggableharness_hook_v1_rpc_response_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DispatchHookResponse_ObserveAck) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DispatchHookResponse_ObserveAck) ProtoMessage() {} + +func (x *DispatchHookResponse_ObserveAck) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_hook_v1_rpc_response_proto_msgTypes[1] + 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 DispatchHookResponse_ObserveAck.ProtoReflect.Descriptor instead. +func (*DispatchHookResponse_ObserveAck) Descriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_rpc_response_proto_rawDescGZIP(), []int{0, 0} +} + +// TransformResult carries a transform subscriber's modified payload. +type DispatchHookResponse_TransformResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // MUST be the same oneof variant as the request's payload. MUST only + // mutate the fields this hook point documents as transform-mutable + // (see each *Payload message's own comment) — the kernel MUST reject + // a response that changes an immutable field or the variant itself, + // per agent-loop/hook-dispatch.md#subscriber-error-handling. + Payload *HookPayload `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DispatchHookResponse_TransformResult) Reset() { + *x = DispatchHookResponse_TransformResult{} + mi := &file_pluggableharness_hook_v1_rpc_response_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DispatchHookResponse_TransformResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DispatchHookResponse_TransformResult) ProtoMessage() {} + +func (x *DispatchHookResponse_TransformResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_hook_v1_rpc_response_proto_msgTypes[2] + 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 DispatchHookResponse_TransformResult.ProtoReflect.Descriptor instead. +func (*DispatchHookResponse_TransformResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_rpc_response_proto_rawDescGZIP(), []int{0, 1} +} + +func (x *DispatchHookResponse_TransformResult) GetPayload() *HookPayload { + if x != nil { + return x.Payload + } + return nil +} + +// VetoResult carries a veto subscriber's allow/deny verdict. +type DispatchHookResponse_VetoResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // MUST be set. HOOK_DECISION_UNSPECIFIED is treated as an invalid + // response (HOOK_ERROR_CATEGORY_INVALID_RESPONSE), not as an implicit + // deny — the fail-closed behavior for a genuinely absent/erroring + // response is handled at the gRPC-status level, not by this enum's + // zero value. + Decision HookDecision `protobuf:"varint,1,opt,name=decision,proto3,enum=pluggableharness.hook.v1.HookDecision" json:"decision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DispatchHookResponse_VetoResult) Reset() { + *x = DispatchHookResponse_VetoResult{} + mi := &file_pluggableharness_hook_v1_rpc_response_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DispatchHookResponse_VetoResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DispatchHookResponse_VetoResult) ProtoMessage() {} + +func (x *DispatchHookResponse_VetoResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_hook_v1_rpc_response_proto_msgTypes[3] + 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 DispatchHookResponse_VetoResult.ProtoReflect.Descriptor instead. +func (*DispatchHookResponse_VetoResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_rpc_response_proto_rawDescGZIP(), []int{0, 2} +} + +func (x *DispatchHookResponse_VetoResult) GetDecision() HookDecision { + if x != nil { + return x.Decision + } + return HookDecision_HOOK_DECISION_UNSPECIFIED +} + +var File_pluggableharness_hook_v1_rpc_response_proto protoreflect.FileDescriptor + +const file_pluggableharness_hook_v1_rpc_response_proto_rawDesc = "" + + "\n" + + "+pluggableharness/hook/v1/rpc_response.proto\x12\x18pluggableharness.hook.v1\x1a%pluggableharness/hook/v1/events.proto\x1a$pluggableharness/hook/v1/types.proto\"\xdd\x03\n" + + "\x14DispatchHookResponse\x12U\n" + + "\aobserve\x18\x01 \x01(\v29.pluggableharness.hook.v1.DispatchHookResponse.ObserveAckH\x00R\aobserve\x12^\n" + + "\ttransform\x18\x02 \x01(\v2>.pluggableharness.hook.v1.DispatchHookResponse.TransformResultH\x00R\ttransform\x12O\n" + + "\x04veto\x18\x03 \x01(\v29.pluggableharness.hook.v1.DispatchHookResponse.VetoResultH\x00R\x04veto\x1a\f\n" + + "\n" + + "ObserveAck\x1aR\n" + + "\x0fTransformResult\x12?\n" + + "\apayload\x18\x01 \x01(\v2%.pluggableharness.hook.v1.HookPayloadR\apayload\x1aP\n" + + "\n" + + "VetoResult\x12B\n" + + "\bdecision\x18\x01 \x01(\x0e2&.pluggableharness.hook.v1.HookDecisionR\bdecisionB\t\n" + + "\aoutcomeB pluggableharness.hook.v1.DispatchHookResponse.ObserveAck + 2, // 1: pluggableharness.hook.v1.DispatchHookResponse.transform:type_name -> pluggableharness.hook.v1.DispatchHookResponse.TransformResult + 3, // 2: pluggableharness.hook.v1.DispatchHookResponse.veto:type_name -> pluggableharness.hook.v1.DispatchHookResponse.VetoResult + 4, // 3: pluggableharness.hook.v1.DispatchHookResponse.TransformResult.payload:type_name -> pluggableharness.hook.v1.HookPayload + 5, // 4: pluggableharness.hook.v1.DispatchHookResponse.VetoResult.decision:type_name -> pluggableharness.hook.v1.HookDecision + 5, // [5:5] is the sub-list for method output_type + 5, // [5:5] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_pluggableharness_hook_v1_rpc_response_proto_init() } +func file_pluggableharness_hook_v1_rpc_response_proto_init() { + if File_pluggableharness_hook_v1_rpc_response_proto != nil { + return + } + file_pluggableharness_hook_v1_events_proto_init() + file_pluggableharness_hook_v1_types_proto_init() + file_pluggableharness_hook_v1_rpc_response_proto_msgTypes[0].OneofWrappers = []any{ + (*DispatchHookResponse_Observe)(nil), + (*DispatchHookResponse_Transform)(nil), + (*DispatchHookResponse_Veto)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_hook_v1_rpc_response_proto_rawDesc), len(file_pluggableharness_hook_v1_rpc_response_proto_rawDesc)), + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_hook_v1_rpc_response_proto_goTypes, + DependencyIndexes: file_pluggableharness_hook_v1_rpc_response_proto_depIdxs, + MessageInfos: file_pluggableharness_hook_v1_rpc_response_proto_msgTypes, + }.Build() + File_pluggableharness_hook_v1_rpc_response_proto = out.File + file_pluggableharness_hook_v1_rpc_response_proto_goTypes = nil + file_pluggableharness_hook_v1_rpc_response_proto_depIdxs = nil +} diff --git a/pkg/hook/proto/v1/service.pb.go b/pkg/hook/proto/v1/service.pb.go new file mode 100644 index 0000000..ed021ae --- /dev/null +++ b/pkg/hook/proto/v1/service.pb.go @@ -0,0 +1,86 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/hook/v1/service.proto + +// Package pluggableharness.hook.v1 defines the hook-dispatch RPC surface +// described in agent-loop/hook-dispatch.md and architecture.md §Hook +// dispatch semantics: the wire contract the kernel uses to invoke any +// plugin (of any of the six categories) that declares a `hook{}` block in +// agent.hcl. This is deliberately one shared service rather than a +// per-category RPC — hashicorp/go-plugin (.claude/rules/plugin-runtime.md) +// muxes multiple gRPC services over one broker connection, so the kernel +// dials HookSubscriberService on the same subprocess that already serves +// that plugin's own category service. A plugin with no `hook{}` blocks in +// agent.hcl simply never has it called. +// +// context-assemble is deliberately absent from this surface's hook-point +// vocabulary (common.v1.HookPoint) — it stays on ContextService.Contribute +// (context/protocol.md#contribute-the-context-assemble-rpc; architecture.md +// §Hook dispatch semantics), which already carries the full accumulated +// ContextSection chain and doesn't need a second, competing dispatch path. +// This surface serves the other eight hook points only. + +package hookv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_pluggableharness_hook_v1_service_proto protoreflect.FileDescriptor + +const file_pluggableharness_hook_v1_service_proto_rawDesc = "" + + "\n" + + "&pluggableharness/hook/v1/service.proto\x12\x18pluggableharness.hook.v1\x1a*pluggableharness/hook/v1/rpc_request.proto\x1a+pluggableharness/hook/v1/rpc_response.proto2\x86\x01\n" + + "\x15HookSubscriberService\x12m\n" + + "\fDispatchHook\x12-.pluggableharness.hook.v1.DispatchHookRequest\x1a..pluggableharness.hook.v1.DispatchHookResponseB pluggableharness.hook.v1.DispatchHookRequest + 1, // 1: pluggableharness.hook.v1.HookSubscriberService.DispatchHook:output_type -> pluggableharness.hook.v1.DispatchHookResponse + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_pluggableharness_hook_v1_service_proto_init() } +func file_pluggableharness_hook_v1_service_proto_init() { + if File_pluggableharness_hook_v1_service_proto != nil { + return + } + file_pluggableharness_hook_v1_rpc_request_proto_init() + file_pluggableharness_hook_v1_rpc_response_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_hook_v1_service_proto_rawDesc), len(file_pluggableharness_hook_v1_service_proto_rawDesc)), + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_pluggableharness_hook_v1_service_proto_goTypes, + DependencyIndexes: file_pluggableharness_hook_v1_service_proto_depIdxs, + }.Build() + File_pluggableharness_hook_v1_service_proto = out.File + file_pluggableharness_hook_v1_service_proto_goTypes = nil + file_pluggableharness_hook_v1_service_proto_depIdxs = nil +} diff --git a/pkg/hook/proto/v1/hook_grpc.pb.go b/pkg/hook/proto/v1/service_grpc.pb.go similarity index 98% rename from pkg/hook/proto/v1/hook_grpc.pb.go rename to pkg/hook/proto/v1/service_grpc.pb.go index 3ae8740..5b887da 100644 --- a/pkg/hook/proto/v1/hook_grpc.pb.go +++ b/pkg/hook/proto/v1/service_grpc.pb.go @@ -2,7 +2,7 @@ // versions: // - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) -// source: pluggableharness/hook/v1/hook.proto +// source: pluggableharness/hook/v1/service.proto // Package pluggableharness.hook.v1 defines the hook-dispatch RPC surface // described in agent-loop/hook-dispatch.md and architecture.md §Hook @@ -171,5 +171,5 @@ var HookSubscriberService_ServiceDesc = grpc.ServiceDesc{ }, }, Streams: []grpc.StreamDesc{}, - Metadata: "pluggableharness/hook/v1/hook.proto", + Metadata: "pluggableharness/hook/v1/service.proto", } diff --git a/pkg/hook/proto/v1/types.pb.go b/pkg/hook/proto/v1/types.pb.go new file mode 100644 index 0000000..19bbc74 --- /dev/null +++ b/pkg/hook/proto/v1/types.pb.go @@ -0,0 +1,224 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/hook/v1/types.proto + +package hookv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// HookMode is the operator-declared subscription mode for one plugin's +// hook{} block, per architecture.md §Hook dispatch semantics and +// agent-loop/hook-dispatch.md#dispatch-order-and-payload-flow. Mode is +// per-subscription (agent.hcl-declared), not per-payload — it is not +// carried in HookPayload itself, only echoed on DispatchHookRequest so the +// subscriber knows which of DispatchHookResponse's three outcome shapes is +// expected of it. +type HookMode int32 + +const ( + // Zero value. Never valid on the wire; its presence means a caller + // forgot to set the field. + HookMode_HOOK_MODE_UNSPECIFIED HookMode = 0 + // Read-only, fire-and-forget. A raised error or malformed response is + // logged and dispatch continues; an observe subscriber can never alter + // the payload or abort the chain + // (agent-loop/hook-dispatch.md#subscriber-error-handling). + HookMode_HOOK_MODE_OBSERVE HookMode = 1 + // Sequential chain member: receives the prior stage's payload, returns + // a modified version of the same variant. An error or malformed + // response aborts the remainder of that hook's chain and surfaces a + // hook_error event (agent-loop/hook-dispatch.md#subscriber-error-handling). + HookMode_HOOK_MODE_TRANSFORM HookMode = 2 + // Returns an explicit allow/deny verdict. An error or timeout is + // treated identically to an explicit deny — fail-closed + // (agent-loop/hook-dispatch.md#timeout-behavior). + HookMode_HOOK_MODE_VETO HookMode = 3 +) + +// Enum value maps for HookMode. +var ( + HookMode_name = map[int32]string{ + 0: "HOOK_MODE_UNSPECIFIED", + 1: "HOOK_MODE_OBSERVE", + 2: "HOOK_MODE_TRANSFORM", + 3: "HOOK_MODE_VETO", + } + HookMode_value = map[string]int32{ + "HOOK_MODE_UNSPECIFIED": 0, + "HOOK_MODE_OBSERVE": 1, + "HOOK_MODE_TRANSFORM": 2, + "HOOK_MODE_VETO": 3, + } +) + +func (x HookMode) Enum() *HookMode { + p := new(HookMode) + *p = x + return p +} + +func (x HookMode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (HookMode) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_hook_v1_types_proto_enumTypes[0].Descriptor() +} + +func (HookMode) Type() protoreflect.EnumType { + return &file_pluggableharness_hook_v1_types_proto_enumTypes[0] +} + +func (x HookMode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use HookMode.Descriptor instead. +func (HookMode) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_types_proto_rawDescGZIP(), []int{0} +} + +// HookDecision is a veto subscriber's coarse allow/deny verdict over a +// whole HookPayload. Deliberately distinct from +// pluggableharness.plan.v1.PlanDecision — that enum is per-plan-item +// and carries PENDING/ASK, which are meaningless for a hook-level veto; +// the two enums MUST NOT be merged, per .claude/rules/proto.md's +// no-untyped-overload discipline (see also ToolErrorCategory vs +// ModelErrorCategory for the same non-merge precedent). +type HookDecision int32 + +const ( + // Zero value. Never valid as a deliberately-chosen verdict; its + // presence on the wire means a caller forgot to set the field. + HookDecision_HOOK_DECISION_UNSPECIFIED HookDecision = 0 + // The dispatch chain proceeds normally. + HookDecision_HOOK_DECISION_ALLOW HookDecision = 1 + // The kernel MUST NOT let the vetoed action proceed. What "the action" + // means is point-specific: at HOOK_POINT_PLAN_READY it means the whole + // plan (or, for the kernel-privileged policy subscriber, the + // per-item decisions policy itself produces directly — a third-party + // veto subscriber at plan-ready returns only this coarse ALLOW/DENY + // over the whole plan, per architecture.md §Policy — first-party, not a + // plugin category). + HookDecision_HOOK_DECISION_DENY HookDecision = 2 +) + +// Enum value maps for HookDecision. +var ( + HookDecision_name = map[int32]string{ + 0: "HOOK_DECISION_UNSPECIFIED", + 1: "HOOK_DECISION_ALLOW", + 2: "HOOK_DECISION_DENY", + } + HookDecision_value = map[string]int32{ + "HOOK_DECISION_UNSPECIFIED": 0, + "HOOK_DECISION_ALLOW": 1, + "HOOK_DECISION_DENY": 2, + } +) + +func (x HookDecision) Enum() *HookDecision { + p := new(HookDecision) + *p = x + return p +} + +func (x HookDecision) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (HookDecision) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_hook_v1_types_proto_enumTypes[1].Descriptor() +} + +func (HookDecision) Type() protoreflect.EnumType { + return &file_pluggableharness_hook_v1_types_proto_enumTypes[1] +} + +func (x HookDecision) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use HookDecision.Descriptor instead. +func (HookDecision) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_hook_v1_types_proto_rawDescGZIP(), []int{1} +} + +var File_pluggableharness_hook_v1_types_proto protoreflect.FileDescriptor + +const file_pluggableharness_hook_v1_types_proto_rawDesc = "" + + "\n" + + "$pluggableharness/hook/v1/types.proto\x12\x18pluggableharness.hook.v1*i\n" + + "\bHookMode\x12\x19\n" + + "\x15HOOK_MODE_UNSPECIFIED\x10\x00\x12\x15\n" + + "\x11HOOK_MODE_OBSERVE\x10\x01\x12\x17\n" + + "\x13HOOK_MODE_TRANSFORM\x10\x02\x12\x12\n" + + "\x0eHOOK_MODE_VETO\x10\x03*^\n" + + "\fHookDecision\x12\x1d\n" + + "\x19HOOK_DECISION_UNSPECIFIED\x10\x00\x12\x17\n" + + "\x13HOOK_DECISION_ALLOW\x10\x01\x12\x16\n" + + "\x12HOOK_DECISION_DENY\x10\x02B 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + + if err := sub.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(received) != 1 || received[0].GetTopic() != "plugin.tool.github.file_changed" { + t.Fatalf("received = %+v, want one event on plugin.tool.github.file_changed", received) + } +} + +func TestClient_Subscribe_closeStopsReceiving(t *testing.T) { + t.Parallel() + + streamStarted := make(chan struct{}) + srv := &fakeServer{ + subscribeFunc: func(_ *kernelv1.SubscribeRequest, stream kernelv1.KernelCallbackService_SubscribeServer) error { + close(streamStarted) + <-stream.Context().Done() + return nil + }, + } + c := newTestClient(t, srv) + + sub, err := c.Subscribe(t.Context(), []string{"kernel.*"}, func(*kernelv1.BusEvent) {}) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + + select { + case <-streamStarted: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for the fake server's Subscribe to start") + } + + closed := make(chan struct{}) + go func() { + _ = sub.Close() + close(closed) + }() + + select { + case <-closed: + case <-time.After(2 * time.Second): + t.Fatal("Close did not return") + } +} diff --git a/pkg/kernel/events.go b/pkg/kernel/events.go new file mode 100644 index 0000000..cb6a435 --- /dev/null +++ b/pkg/kernel/events.go @@ -0,0 +1,63 @@ +package kernel + +import ( + "context" + "fmt" + + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" +) + +// StoredEventHandler is invoked once per *kernelv1.StoredEvent a ReadEvents +// stream receives, in the kernel's ascending-sequence delivery order +// (kernel-callbacks.md#readevents — never by time, per +// .claude/rules/determinism.md), on that stream's own dedicated receive +// goroutine — never on the caller's own goroutine, mirroring +// BusEventHandler's shape in eventbus.go. +type StoredEventHandler func(event *kernelv1.StoredEvent) + +// ReadEvents opens a server-streaming read-back of the calling plugin's own +// session's persisted event log, ordered by sequence +// (kernel-callbacks.md#readevents), invoking handler once per StoredEvent +// on a dedicated goroutine this method owns — the same shape Subscribe +// (eventbus.go) already uses, rather than a second streaming idiom in this +// package. The returned Subscription's Close stops receiving early; unlike +// Subscribe's open-ended bus subscription, a ReadEvents stream is also +// naturally finite on its own — the kernel closes it once every matching +// event (bounded by req.Limit, if set) has been delivered, at which point +// handler simply stops being called and the Subscription's internal +// goroutine exits without the caller needing to call Close at all. +// +// req.SessionId is mandatory and MUST name the session this plugin was +// actually invoked for — the same one-session-only rule Emit documents; +// the kernel rejects any other value. req.Kinds MAY be empty (meaning +// every kind); req.FromSequence and req.Limit MAY be omitted (meaning +// "from the start of the log" and "no limit," respectively). req is passed +// through directly rather than exploded into discrete parameters: one +// mandatory field plus three independently-optional ones is exactly the +// case where an options-struct-of-parameters would just reproduce the +// generated type's own shape. +func (c *Client) ReadEvents(ctx context.Context, req *kernelv1.ReadEventsRequest, handler StoredEventHandler) (*Subscription, error) { + streamCtx, cancel := context.WithCancel(ctx) + stream, err := c.raw.ReadEvents(streamCtx, req) + if err != nil { + cancel() + return nil, fmt.Errorf("kernel: read events: %w", err) + } + + sub := &Subscription{cancel: cancel, done: make(chan struct{})} + go func() { + defer close(sub.done) + for { + event, err := stream.Recv() + if err != nil { + // Stream ended — naturally (every matching event delivered, + // the common case for this RPC), via Close's cancel, or an + // ordinary EOF. Nothing further to receive either way; same + // no-error-surfaced-from-this-goroutine shape Subscribe uses. + return + } + handler(event) + } + }() + return sub, nil +} diff --git a/pkg/kernel/events_test.go b/pkg/kernel/events_test.go new file mode 100644 index 0000000..6e09e07 --- /dev/null +++ b/pkg/kernel/events_test.go @@ -0,0 +1,98 @@ +package kernel_test + +import ( + "sync" + "testing" + "time" + + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" +) + +func TestClient_ReadEvents_receivesEvents(t *testing.T) { + t.Parallel() + + srv := &fakeServer{ + readEventsFunc: func(req *kernelv1.ReadEventsRequest, stream kernelv1.KernelCallbackService_ReadEventsServer) error { + if req.GetSessionId() != "session-01" { + t.Errorf("server received session_id %q, want session-01", req.GetSessionId()) + } + if err := stream.Send(&kernelv1.StoredEvent{Sequence: 1, Id: "evt-01"}); err != nil { + return err + } + if err := stream.Send(&kernelv1.StoredEvent{Sequence: 2, Id: "evt-02"}); err != nil { + return err + } + return nil + }, + } + c := newTestClient(t, srv) + + var mu sync.Mutex + var received []*kernelv1.StoredEvent + sub, err := c.ReadEvents(t.Context(), &kernelv1.ReadEventsRequest{SessionId: "session-01"}, func(ev *kernelv1.StoredEvent) { + mu.Lock() + defer mu.Unlock() + received = append(received, ev) + }) + if err != nil { + t.Fatalf("ReadEvents: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + n := len(received) + mu.Unlock() + if n >= 2 { + break + } + time.Sleep(10 * time.Millisecond) + } + + if err := sub.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(received) != 2 || received[0].GetSequence() != 1 || received[1].GetSequence() != 2 { + t.Fatalf("received = %+v, want two events with sequence 1, 2", received) + } +} + +func TestClient_ReadEvents_closeStopsReceiving(t *testing.T) { + t.Parallel() + + streamStarted := make(chan struct{}) + srv := &fakeServer{ + readEventsFunc: func(_ *kernelv1.ReadEventsRequest, stream kernelv1.KernelCallbackService_ReadEventsServer) error { + close(streamStarted) + <-stream.Context().Done() + return nil + }, + } + c := newTestClient(t, srv) + + sub, err := c.ReadEvents(t.Context(), &kernelv1.ReadEventsRequest{SessionId: "session-01"}, func(*kernelv1.StoredEvent) {}) + if err != nil { + t.Fatalf("ReadEvents: %v", err) + } + + select { + case <-streamStarted: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for the fake server's ReadEvents to start") + } + + closed := make(chan struct{}) + go func() { + _ = sub.Close() + close(closed) + }() + + select { + case <-closed: + case <-time.After(2 * time.Second): + t.Fatal("Close did not return") + } +} diff --git a/pkg/kernel/helpers_test.go b/pkg/kernel/helpers_test.go new file mode 100644 index 0000000..4e168b7 --- /dev/null +++ b/pkg/kernel/helpers_test.go @@ -0,0 +1,138 @@ +package kernel_test + +import ( + "context" + "net" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + "github.com/pluggableharness/agent/pkg/kernel" + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" +) + +// newTestClient starts srv on an in-memory bufconn listener and returns a +// *kernel.Client dialed against it — a real gRPC round trip, not a hand- +// rolled interface fake, so these tests exercise the actual wire +// marshaling this package's translation code produces. +func newTestClient(t *testing.T, srv kernelv1.KernelCallbackServiceServer) *kernel.Client { + t.Helper() + + const bufSize = 1 << 20 + lis := bufconn.Listen(bufSize) + + gs := grpc.NewServer() + kernelv1.RegisterKernelCallbackServiceServer(gs, srv) + go func() { _ = gs.Serve(lis) }() + t.Cleanup(gs.Stop) + + dialer := func(context.Context, string) (net.Conn, error) { return lis.Dial() } + conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(dialer), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("grpc.NewClient: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + return kernel.NewClient(conn) +} + +// fakeServer is a hand-written kernelv1.KernelCallbackServiceServer fake +// (go-testing.md: fakes, not mocking frameworks). Each RPC's behavior is +// controlled by a caller-set func field; a nil field falls through to the +// embedded UnimplementedKernelCallbackServiceServer's codes.Unimplemented. +type fakeServer struct { + kernelv1.UnimplementedKernelCallbackServiceServer + + logFunc func(*kernelv1.LogRequest) (*kernelv1.LogResult, error) + exportSpansFunc func(*kernelv1.ExportSpansRequest) (*kernelv1.ExportSpansResult, error) + getTelemetryConfigFunc func(*kernelv1.GetTelemetryConfigRequest) (*kernelv1.GetTelemetryConfigResult, error) + getConfigFunc func(*kernelv1.GetConfigRequest) (*kernelv1.GetConfigResult, error) + publishFunc func(*kernelv1.PublishRequest) (*kernelv1.PublishResult, error) + subscribeFunc func(*kernelv1.SubscribeRequest, kernelv1.KernelCallbackService_SubscribeServer) error + runSessionFunc func(*kernelv1.RunSessionRequest) (*kernelv1.RunSessionResult, error) + countTokensFunc func(*kernelv1.CountTokensRequest) (*kernelv1.CountTokensResult, error) + emitFunc func(*kernelv1.EmitRequest) (*kernelv1.EmitResult, error) + getSessionFunc func(*kernelv1.GetSessionRequest) (*kernelv1.GetSessionResult, error) + readEventsFunc func(*kernelv1.ReadEventsRequest, kernelv1.KernelCallbackService_ReadEventsServer) error +} + +func (f *fakeServer) Log(ctx context.Context, req *kernelv1.LogRequest) (*kernelv1.LogResult, error) { + if f.logFunc != nil { + return f.logFunc(req) + } + return f.UnimplementedKernelCallbackServiceServer.Log(ctx, req) +} + +func (f *fakeServer) ExportSpans(ctx context.Context, req *kernelv1.ExportSpansRequest) (*kernelv1.ExportSpansResult, error) { + if f.exportSpansFunc != nil { + return f.exportSpansFunc(req) + } + return f.UnimplementedKernelCallbackServiceServer.ExportSpans(ctx, req) +} + +func (f *fakeServer) GetTelemetryConfig(ctx context.Context, req *kernelv1.GetTelemetryConfigRequest) (*kernelv1.GetTelemetryConfigResult, error) { + if f.getTelemetryConfigFunc != nil { + return f.getTelemetryConfigFunc(req) + } + return f.UnimplementedKernelCallbackServiceServer.GetTelemetryConfig(ctx, req) +} + +func (f *fakeServer) GetConfig(ctx context.Context, req *kernelv1.GetConfigRequest) (*kernelv1.GetConfigResult, error) { + if f.getConfigFunc != nil { + return f.getConfigFunc(req) + } + return f.UnimplementedKernelCallbackServiceServer.GetConfig(ctx, req) +} + +func (f *fakeServer) Publish(ctx context.Context, req *kernelv1.PublishRequest) (*kernelv1.PublishResult, error) { + if f.publishFunc != nil { + return f.publishFunc(req) + } + return f.UnimplementedKernelCallbackServiceServer.Publish(ctx, req) +} + +func (f *fakeServer) Subscribe(req *kernelv1.SubscribeRequest, stream kernelv1.KernelCallbackService_SubscribeServer) error { + if f.subscribeFunc != nil { + return f.subscribeFunc(req, stream) + } + return f.UnimplementedKernelCallbackServiceServer.Subscribe(req, stream) +} + +func (f *fakeServer) RunSession(ctx context.Context, req *kernelv1.RunSessionRequest) (*kernelv1.RunSessionResult, error) { + if f.runSessionFunc != nil { + return f.runSessionFunc(req) + } + return f.UnimplementedKernelCallbackServiceServer.RunSession(ctx, req) +} + +func (f *fakeServer) CountTokens(ctx context.Context, req *kernelv1.CountTokensRequest) (*kernelv1.CountTokensResult, error) { + if f.countTokensFunc != nil { + return f.countTokensFunc(req) + } + return f.UnimplementedKernelCallbackServiceServer.CountTokens(ctx, req) +} + +func (f *fakeServer) Emit(ctx context.Context, req *kernelv1.EmitRequest) (*kernelv1.EmitResult, error) { + if f.emitFunc != nil { + return f.emitFunc(req) + } + return f.UnimplementedKernelCallbackServiceServer.Emit(ctx, req) +} + +func (f *fakeServer) GetSession(ctx context.Context, req *kernelv1.GetSessionRequest) (*kernelv1.GetSessionResult, error) { + if f.getSessionFunc != nil { + return f.getSessionFunc(req) + } + return f.UnimplementedKernelCallbackServiceServer.GetSession(ctx, req) +} + +func (f *fakeServer) ReadEvents(req *kernelv1.ReadEventsRequest, stream kernelv1.KernelCallbackService_ReadEventsServer) error { + if f.readEventsFunc != nil { + return f.readEventsFunc(req, stream) + } + return f.UnimplementedKernelCallbackServiceServer.ReadEvents(req, stream) +} + +var _ kernelv1.KernelCallbackServiceServer = (*fakeServer)(nil) diff --git a/pkg/kernel/level.go b/pkg/kernel/level.go new file mode 100644 index 0000000..8d8cb89 --- /dev/null +++ b/pkg/kernel/level.go @@ -0,0 +1,64 @@ +package kernel + +import ( + "log/slog" + + logv1 "github.com/pluggableharness/agent/pkg/log/proto/v1" +) + +// levelToWire and wireToLevel translate between log/slog's level model and +// the wire LogLevel enum, using the exact TRACE-below-Debug/FATAL-above-Error +// boundaries specifications/kernel-callbacks.md#log documents +// ("the kernel MUST translate LOG_LEVEL_TRACE to a custom slog.Level +// below slog.LevelDebug and LOG_LEVEL_FATAL to one above slog.LevelError"). +// +// These boundaries are duplicated here rather than imported from +// internal/log's LevelTrace/LevelFatal constants: pkg/ is the +// plugin-author-consumable surface and internal/ is kernel-only +// (go-layout.md's package boundary) — pkg/kernel must not depend on +// internal/log just to share two arithmetic constants. If this project's +// canonical level-boundary arithmetic ever changes, both copies need +// updating together; there is no way around that duplication without +// crossing the pkg//internal boundary this package deliberately doesn't. + +// levelToWire converts an slog.Level to the wire LogLevel enum. +func levelToWire(level slog.Level) logv1.LogLevel { + switch { + case level < slog.LevelDebug: + return logv1.LogLevel_LOG_LEVEL_TRACE + case level < slog.LevelInfo: + return logv1.LogLevel_LOG_LEVEL_DEBUG + case level < slog.LevelWarn: + return logv1.LogLevel_LOG_LEVEL_INFO + case level < slog.LevelError: + return logv1.LogLevel_LOG_LEVEL_WARN + case level <= slog.LevelError: + return logv1.LogLevel_LOG_LEVEL_ERROR + default: + return logv1.LogLevel_LOG_LEVEL_FATAL + } +} + +// wireToLevel converts the wire LogLevel enum to an slog.Level — the +// inverse of levelToWire, used to translate GetTelemetryConfig's reported +// floor into a level a caller's own slog.Handler.Enabled check can +// compare against. LOG_LEVEL_UNSPECIFIED (never valid on the wire) and any +// unrecognized value fall back to slog.LevelInfo. +func wireToLevel(level logv1.LogLevel) slog.Level { + switch level { + case logv1.LogLevel_LOG_LEVEL_TRACE: + return slog.LevelDebug - 4 + case logv1.LogLevel_LOG_LEVEL_DEBUG: + return slog.LevelDebug + case logv1.LogLevel_LOG_LEVEL_INFO: + return slog.LevelInfo + case logv1.LogLevel_LOG_LEVEL_WARN: + return slog.LevelWarn + case logv1.LogLevel_LOG_LEVEL_ERROR: + return slog.LevelError + case logv1.LogLevel_LOG_LEVEL_FATAL: + return slog.LevelError + 4 + default: + return slog.LevelInfo + } +} diff --git a/pkg/kernel/level_test.go b/pkg/kernel/level_test.go new file mode 100644 index 0000000..8c8fe91 --- /dev/null +++ b/pkg/kernel/level_test.go @@ -0,0 +1,68 @@ +package kernel + +import ( + "log/slog" + "testing" + + logv1 "github.com/pluggableharness/agent/pkg/log/proto/v1" +) + +func TestLevelToWire(t *testing.T) { + t.Parallel() + + tests := []struct { + level slog.Level + want logv1.LogLevel + }{ + {slog.LevelDebug - 4, logv1.LogLevel_LOG_LEVEL_TRACE}, + {slog.LevelDebug, logv1.LogLevel_LOG_LEVEL_DEBUG}, + {slog.LevelInfo, logv1.LogLevel_LOG_LEVEL_INFO}, + {slog.LevelWarn, logv1.LogLevel_LOG_LEVEL_WARN}, + {slog.LevelError, logv1.LogLevel_LOG_LEVEL_ERROR}, + {slog.LevelError + 4, logv1.LogLevel_LOG_LEVEL_FATAL}, + } + for _, tt := range tests { + if got := levelToWire(tt.level); got != tt.want { + t.Errorf("levelToWire(%v) = %v, want %v", tt.level, got, tt.want) + } + } +} + +func TestWireToLevel(t *testing.T) { + t.Parallel() + + tests := []struct { + level logv1.LogLevel + want slog.Level + }{ + {logv1.LogLevel_LOG_LEVEL_TRACE, slog.LevelDebug - 4}, + {logv1.LogLevel_LOG_LEVEL_DEBUG, slog.LevelDebug}, + {logv1.LogLevel_LOG_LEVEL_INFO, slog.LevelInfo}, + {logv1.LogLevel_LOG_LEVEL_WARN, slog.LevelWarn}, + {logv1.LogLevel_LOG_LEVEL_ERROR, slog.LevelError}, + {logv1.LogLevel_LOG_LEVEL_FATAL, slog.LevelError + 4}, + {logv1.LogLevel_LOG_LEVEL_UNSPECIFIED, slog.LevelInfo}, + } + for _, tt := range tests { + if got := wireToLevel(tt.level); got != tt.want { + t.Errorf("wireToLevel(%v) = %v, want %v", tt.level, got, tt.want) + } + } +} + +func TestLevelRoundTrip(t *testing.T) { + t.Parallel() + + for _, level := range []logv1.LogLevel{ + logv1.LogLevel_LOG_LEVEL_TRACE, + logv1.LogLevel_LOG_LEVEL_DEBUG, + logv1.LogLevel_LOG_LEVEL_INFO, + logv1.LogLevel_LOG_LEVEL_WARN, + logv1.LogLevel_LOG_LEVEL_ERROR, + logv1.LogLevel_LOG_LEVEL_FATAL, + } { + if got := levelToWire(wireToLevel(level)); got != level { + t.Errorf("levelToWire(wireToLevel(%v)) = %v, want %v", level, got, level) + } + } +} diff --git a/pkg/kernel/proto/v1/events.pb.go b/pkg/kernel/proto/v1/events.pb.go new file mode 100644 index 0000000..39e112a --- /dev/null +++ b/pkg/kernel/proto/v1/events.pb.go @@ -0,0 +1,300 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/kernel/v1/events.proto + +package kernelv1 + +import ( + v1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// BusEvent is one event delivered to a Subscribe stream. See +// kernel-callbacks.md's Subscribe and event-bus.md. +type BusEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The event's fully-resolved topic. See event-bus.md#topic-grammar. + Topic string `protobuf:"bytes,1,opt,name=topic,proto3" json:"topic,omitempty"` + // The event payload, exactly as published. MAY be empty. Opaque to the + // kernel — see PublishRequest.payload (rpc_request.proto). + Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` + // Identifies payload's shape. See PublishRequest.payload_type. + PayloadType string `protobuf:"bytes,3,opt,name=payload_type,json=payloadType,proto3" json:"payload_type,omitempty"` + // Versions payload_type. See PublishRequest.schema_version. + SchemaVersion string `protobuf:"bytes,4,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` + // When the kernel received the Publish call this event fans out from. + // MUST be set. Display-only — this bus assigns no sequence number and + // makes no cross-subscriber ordering guarantee + // (event-bus.md#delivery-semantics). + Time *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=time,proto3" json:"time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BusEvent) Reset() { + *x = BusEvent{} + mi := &file_pluggableharness_kernel_v1_events_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BusEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BusEvent) ProtoMessage() {} + +func (x *BusEvent) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_events_proto_msgTypes[0] + 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 BusEvent.ProtoReflect.Descriptor instead. +func (*BusEvent) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_events_proto_rawDescGZIP(), []int{0} +} + +func (x *BusEvent) GetTopic() string { + if x != nil { + return x.Topic + } + return "" +} + +func (x *BusEvent) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +func (x *BusEvent) GetPayloadType() string { + if x != nil { + return x.PayloadType + } + return "" +} + +func (x *BusEvent) GetSchemaVersion() string { + if x != nil { + return x.SchemaVersion + } + return "" +} + +func (x *BusEvent) GetTime() *timestamppb.Timestamp { + if x != nil { + return x.Time + } + return nil +} + +// StoredEvent is one persisted event, read back by ReadEvents. Mirrors +// state-backend.md's events table row. See kernel-callbacks.md's +// ReadEvents. +type StoredEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The row's ordering-authoritative sequence number. MUST be set. + // ReadEvents streams StoredEvents in ascending sequence order — never + // by time (.claude/rules/determinism.md). + Sequence int64 `protobuf:"varint,1,opt,name=sequence,proto3" json:"sequence,omitempty"` + // The stable, storage-independent event id. MUST be set. + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + // When this event occurred, wall-clock, display-only — never used to + // order anything (.claude/rules/determinism.md). MUST be set. + Time *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=time,proto3" json:"time,omitempty"` + // The event's kind. MUST be set. + Kind EventKind `protobuf:"varint,4,opt,name=kind,proto3,enum=pluggableharness.kernel.v1.EventKind" json:"kind,omitempty"` + // The plugin that originally Emit'd this event, read back from + // storage — unlike EmitRequest, which never carries a producer field, + // this is server-populated on write and simply returned here, not + // server-derived from the calling connection (the caller reading this + // back may be a different plugin than the one that emitted it). MUST + // be set. + Producer *v1.ProducerRef `protobuf:"bytes,5,opt,name=producer,proto3" json:"producer,omitempty"` + // The schema version of `payload`. MUST be set. See + // EmitRequest.schema_version. + SchemaVersion string `protobuf:"bytes,6,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` + // The event payload, exactly as the originating Emit call wrote it. + // Opaque to the kernel — see EmitRequest.payload. + Payload []byte `protobuf:"bytes,7,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoredEvent) Reset() { + *x = StoredEvent{} + mi := &file_pluggableharness_kernel_v1_events_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoredEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoredEvent) ProtoMessage() {} + +func (x *StoredEvent) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_events_proto_msgTypes[1] + 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 StoredEvent.ProtoReflect.Descriptor instead. +func (*StoredEvent) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_events_proto_rawDescGZIP(), []int{1} +} + +func (x *StoredEvent) GetSequence() int64 { + if x != nil { + return x.Sequence + } + return 0 +} + +func (x *StoredEvent) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *StoredEvent) GetTime() *timestamppb.Timestamp { + if x != nil { + return x.Time + } + return nil +} + +func (x *StoredEvent) GetKind() EventKind { + if x != nil { + return x.Kind + } + return EventKind_EVENT_KIND_UNSPECIFIED +} + +func (x *StoredEvent) GetProducer() *v1.ProducerRef { + if x != nil { + return x.Producer + } + return nil +} + +func (x *StoredEvent) GetSchemaVersion() string { + if x != nil { + return x.SchemaVersion + } + return "" +} + +func (x *StoredEvent) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +var File_pluggableharness_kernel_v1_events_proto protoreflect.FileDescriptor + +const file_pluggableharness_kernel_v1_events_proto_rawDesc = "" + + "\n" + + "'pluggableharness/kernel/v1/events.proto\x12\x1apluggableharness.kernel.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/kernel/v1/types.proto\"\xb4\x01\n" + + "\bBusEvent\x12\x14\n" + + "\x05topic\x18\x01 \x01(\tR\x05topic\x12\x18\n" + + "\apayload\x18\x02 \x01(\fR\apayload\x12!\n" + + "\fpayload_type\x18\x03 \x01(\tR\vpayloadType\x12%\n" + + "\x0eschema_version\x18\x04 \x01(\tR\rschemaVersion\x12.\n" + + "\x04time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\x04time\"\xaa\x02\n" + + "\vStoredEvent\x12\x1a\n" + + "\bsequence\x18\x01 \x01(\x03R\bsequence\x12\x0e\n" + + "\x02id\x18\x02 \x01(\tR\x02id\x12.\n" + + "\x04time\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x04time\x129\n" + + "\x04kind\x18\x04 \x01(\x0e2%.pluggableharness.kernel.v1.EventKindR\x04kind\x12C\n" + + "\bproducer\x18\x05 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\bproducer\x12%\n" + + "\x0eschema_version\x18\x06 \x01(\tR\rschemaVersion\x12\x18\n" + + "\apayload\x18\a \x01(\fR\apayloadB@Z>github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1b\x06proto3" + +var ( + file_pluggableharness_kernel_v1_events_proto_rawDescOnce sync.Once + file_pluggableharness_kernel_v1_events_proto_rawDescData []byte +) + +func file_pluggableharness_kernel_v1_events_proto_rawDescGZIP() []byte { + file_pluggableharness_kernel_v1_events_proto_rawDescOnce.Do(func() { + file_pluggableharness_kernel_v1_events_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_kernel_v1_events_proto_rawDesc), len(file_pluggableharness_kernel_v1_events_proto_rawDesc))) + }) + return file_pluggableharness_kernel_v1_events_proto_rawDescData +} + +var file_pluggableharness_kernel_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_pluggableharness_kernel_v1_events_proto_goTypes = []any{ + (*BusEvent)(nil), // 0: pluggableharness.kernel.v1.BusEvent + (*StoredEvent)(nil), // 1: pluggableharness.kernel.v1.StoredEvent + (*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp + (EventKind)(0), // 3: pluggableharness.kernel.v1.EventKind + (*v1.ProducerRef)(nil), // 4: pluggableharness.common.v1.ProducerRef +} +var file_pluggableharness_kernel_v1_events_proto_depIdxs = []int32{ + 2, // 0: pluggableharness.kernel.v1.BusEvent.time:type_name -> google.protobuf.Timestamp + 2, // 1: pluggableharness.kernel.v1.StoredEvent.time:type_name -> google.protobuf.Timestamp + 3, // 2: pluggableharness.kernel.v1.StoredEvent.kind:type_name -> pluggableharness.kernel.v1.EventKind + 4, // 3: pluggableharness.kernel.v1.StoredEvent.producer:type_name -> pluggableharness.common.v1.ProducerRef + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_pluggableharness_kernel_v1_events_proto_init() } +func file_pluggableharness_kernel_v1_events_proto_init() { + if File_pluggableharness_kernel_v1_events_proto != nil { + return + } + file_pluggableharness_kernel_v1_types_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_kernel_v1_events_proto_rawDesc), len(file_pluggableharness_kernel_v1_events_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_kernel_v1_events_proto_goTypes, + DependencyIndexes: file_pluggableharness_kernel_v1_events_proto_depIdxs, + MessageInfos: file_pluggableharness_kernel_v1_events_proto_msgTypes, + }.Build() + File_pluggableharness_kernel_v1_events_proto = out.File + file_pluggableharness_kernel_v1_events_proto_goTypes = nil + file_pluggableharness_kernel_v1_events_proto_depIdxs = nil +} diff --git a/pkg/kernel/proto/v1/kernel.pb.go b/pkg/kernel/proto/v1/kernel.pb.go deleted file mode 100644 index 7b58695..0000000 --- a/pkg/kernel/proto/v1/kernel.pb.go +++ /dev/null @@ -1,852 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: pluggableharness/kernel/v1/kernel.proto - -// Package pluggableharness.kernel.v1 defines the kernel-callback service described -// in specifications/kernel-callbacks.md (RunSession, CountTokens, Emit, -// Log) — the plugin-to-kernel calling direction every plugin category gets -// at handshake, the reverse of every other category's protocol in this -// series. Unlike a category plugin protocol, this service carries no -// GetCapabilities/Configure RPCs: it isn't something the kernel dials into -// a plugin, it's the connection every plugin subprocess is handed back to -// call into the kernel. - -package kernelv1 - -import ( - v1 "github.com/pluggableharness/agent/pkg/common/proto/v1" - v11 "github.com/pluggableharness/agent/pkg/content/proto/v1" - v14 "github.com/pluggableharness/agent/pkg/log/proto/v1" - v13 "github.com/pluggableharness/agent/pkg/model/proto/v1" - v12 "github.com/pluggableharness/agent/pkg/session/proto/v1" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// EventKind identifies the shape of an EmitRequest's opaque payload. This -// is state-backend.md §5's authoritative kind enum, restated here only -// because it is the wire-level type Emit actually carries — -// state-backend.md §5 remains authoritative if the two ever need -// reconciling again. Usage/cost figures, Render() output, and -// session_start/session_end deliberately do NOT get their own EventKind; -// see state-backend.md §5 for why. -type EventKind int32 - -const ( - // Zero value. Never valid on the wire; its presence means a caller - // forgot to set the field. - EventKind_EVENT_KIND_UNSPECIFIED EventKind = 0 - // A completed model turn's canonical message (model.md §5). Usage - // and cost figures live inside this payload, not as separate fields or - // a separate EventKind (state-backend.md §5). - EventKind_EVENT_KIND_MESSAGE EventKind = 1 - // A tool invocation's call. - EventKind_EVENT_KIND_TOOL_CALL EventKind = 2 - // A tool invocation's result. - EventKind_EVENT_KIND_TOOL_RESULT EventKind = 3 - // A built Plan, prior to plan-ready dispatch. - EventKind_EVENT_KIND_PLAN EventKind = 4 - // The outcome of applying a Plan. - EventKind_EVENT_KIND_APPLY EventKind = 5 - // A context provider's Contribute output, or a memory provider's - // Recall output after kernel translation (memory.md §6). - EventKind_EVENT_KIND_CONTEXT_CONTRIBUTION EventKind = 6 - // A memory provider's write of a new record. - EventKind_EVENT_KIND_MEMORY_WRITE EventKind = 7 - // A memory provider's update of an existing record. - EventKind_EVENT_KIND_MEMORY_UPDATE EventKind = 8 - // A memory provider's deletion of an existing record. - EventKind_EVENT_KIND_MEMORY_DELETE EventKind = 9 - // Kernel-synthesized when a transform or veto hook subscriber fails - // (agent-loop/hook-dispatch.md#subscriber-error-handling) — never - // emitted by a plugin's own Emit call, only by the kernel itself - // dispatching a hook. Payload shape is - // pluggableharness.hook.v1.HookError, wrapped by the forthcoming - // event.v1 package's HookErrorEvent (state-backend.md §5). - EventKind_EVENT_KIND_HOOK_ERROR EventKind = 10 -) - -// Enum value maps for EventKind. -var ( - EventKind_name = map[int32]string{ - 0: "EVENT_KIND_UNSPECIFIED", - 1: "EVENT_KIND_MESSAGE", - 2: "EVENT_KIND_TOOL_CALL", - 3: "EVENT_KIND_TOOL_RESULT", - 4: "EVENT_KIND_PLAN", - 5: "EVENT_KIND_APPLY", - 6: "EVENT_KIND_CONTEXT_CONTRIBUTION", - 7: "EVENT_KIND_MEMORY_WRITE", - 8: "EVENT_KIND_MEMORY_UPDATE", - 9: "EVENT_KIND_MEMORY_DELETE", - 10: "EVENT_KIND_HOOK_ERROR", - } - EventKind_value = map[string]int32{ - "EVENT_KIND_UNSPECIFIED": 0, - "EVENT_KIND_MESSAGE": 1, - "EVENT_KIND_TOOL_CALL": 2, - "EVENT_KIND_TOOL_RESULT": 3, - "EVENT_KIND_PLAN": 4, - "EVENT_KIND_APPLY": 5, - "EVENT_KIND_CONTEXT_CONTRIBUTION": 6, - "EVENT_KIND_MEMORY_WRITE": 7, - "EVENT_KIND_MEMORY_UPDATE": 8, - "EVENT_KIND_MEMORY_DELETE": 9, - "EVENT_KIND_HOOK_ERROR": 10, - } -) - -func (x EventKind) Enum() *EventKind { - p := new(EventKind) - *p = x - return p -} - -func (x EventKind) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (EventKind) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_kernel_v1_kernel_proto_enumTypes[0].Descriptor() -} - -func (EventKind) Type() protoreflect.EnumType { - return &file_pluggableharness_kernel_v1_kernel_proto_enumTypes[0] -} - -func (x EventKind) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use EventKind.Descriptor instead. -func (EventKind) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_kernel_v1_kernel_proto_rawDescGZIP(), []int{0} -} - -// RunSessionRequest names the sub-agent profile to dispatch and carries -// the inherited, only-shrinking resource budgets the child session is -// bound by. See agent-loop.md §7. -type RunSessionRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Named sub-agent profile from agent.hcl to run this session under. - // MUST be set. - Profile string `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"` - // The prompt to run the sub-agent session with. - Prompt string `protobuf:"bytes,2,opt,name=prompt,proto3" json:"prompt,omitempty"` - // The calling session's id. MUST be set to the id of the session making - // this RunSession call, establishing the parent/child relationship the - // state backend and replay model rely on. - ParentSessionId string `protobuf:"bytes,3,opt,name=parent_session_id,json=parentSessionId,proto3" json:"parent_session_id,omitempty"` - // Remaining sub-agent nesting depth available to the child session. - // MUST be set. An inherited, only-shrinking budget computed by the - // kernel per configuration.md §8.4 / agent-loop.md §7.5 — the child is - // never able to widen it, only spend down what it was given. - RemainingDepth int32 `protobuf:"varint,4,opt,name=remaining_depth,json=remainingDepth,proto3" json:"remaining_depth,omitempty"` - // Remaining cost budget, in USD, available to the child session. MUST - // be set. Same inherited, only-shrinking shape as remaining_depth, - // computed per agent-loop.md §3.1. - RemainingCostBudgetUsd float64 `protobuf:"fixed64,5,opt,name=remaining_cost_budget_usd,json=remainingCostBudgetUsd,proto3" json:"remaining_cost_budget_usd,omitempty"` - // The set of providers the child session is scoped to, resolved from - // the named profile's declared tool set. A caller MAY narrow this - // further per-call but MUST NOT widen it beyond what the profile - // declares. - ScopedProviders []*v1.ProviderRef `protobuf:"bytes,6,rep,name=scoped_providers,json=scopedProviders,proto3" json:"scoped_providers,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RunSessionRequest) Reset() { - *x = RunSessionRequest{} - mi := &file_pluggableharness_kernel_v1_kernel_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RunSessionRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RunSessionRequest) ProtoMessage() {} - -func (x *RunSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_kernel_v1_kernel_proto_msgTypes[0] - 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 RunSessionRequest.ProtoReflect.Descriptor instead. -func (*RunSessionRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_kernel_v1_kernel_proto_rawDescGZIP(), []int{0} -} - -func (x *RunSessionRequest) GetProfile() string { - if x != nil { - return x.Profile - } - return "" -} - -func (x *RunSessionRequest) GetPrompt() string { - if x != nil { - return x.Prompt - } - return "" -} - -func (x *RunSessionRequest) GetParentSessionId() string { - if x != nil { - return x.ParentSessionId - } - return "" -} - -func (x *RunSessionRequest) GetRemainingDepth() int32 { - if x != nil { - return x.RemainingDepth - } - return 0 -} - -func (x *RunSessionRequest) GetRemainingCostBudgetUsd() float64 { - if x != nil { - return x.RemainingCostBudgetUsd - } - return 0 -} - -func (x *RunSessionRequest) GetScopedProviders() []*v1.ProviderRef { - if x != nil { - return x.ScopedProviders - } - return nil -} - -// RunSessionResult carries the child session's outcome back to the -// calling plugin once the child has reached a terminal state. -type RunSessionResult struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The id of the child session that was created and run. - SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - // The child session's final message — the only thing that crosses the - // session boundary back to the parent turn. Intermediate turns - // produced by the child are never visible to the parent's model - // context (agent-loop.md §7.2), though they remain queryable in the - // state backend for replay and audit. - FinalMessage *v11.Message `protobuf:"bytes,2,opt,name=final_message,json=finalMessage,proto3" json:"final_message,omitempty"` - // The child session's terminal lifecycle status. Never - // SESSION_STATUS_RUNNING — a RunSession call only returns once the - // child session has reached a terminal state. - Status v12.SessionStatus `protobuf:"varint,3,opt,name=status,proto3,enum=pluggableharness.session.v1.SessionStatus" json:"status,omitempty"` - // The child session's aggregate cost, in USD, summed across every turn - // it ran, including any of its own descendant sub-agent sessions. MUST - // be set. Deliberately a flat field, not a - // pluggableharness.model.v1.Usage-shaped reference — this is a - // whole-session rollup (state-backend.md's cost_ledger SUM, per - // .claude/rules/determinism.md's "Cost and budget rollup"), a different - // shape from one completion call's per-call Usage. Lets an orchestrator - // plugin do budget-aware fan-out (spend-check a child's outcome before - // deciding whether to spawn another) without needing to separately sum - // the child's own event history itself. - TotalCostUsd float64 `protobuf:"fixed64,4,opt,name=total_cost_usd,json=totalCostUsd,proto3" json:"total_cost_usd,omitempty"` - // The child session's aggregate input token count, summed across every - // turn it ran, including descendants. MUST be set. Same flat, - // aggregate-not-per-call rationale as total_cost_usd. - TotalInputTokens int64 `protobuf:"varint,5,opt,name=total_input_tokens,json=totalInputTokens,proto3" json:"total_input_tokens,omitempty"` - // The child session's aggregate output token count, summed across - // every turn it ran, including descendants. MUST be set. Same flat, - // aggregate-not-per-call rationale as total_cost_usd. - TotalOutputTokens int64 `protobuf:"varint,6,opt,name=total_output_tokens,json=totalOutputTokens,proto3" json:"total_output_tokens,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RunSessionResult) Reset() { - *x = RunSessionResult{} - mi := &file_pluggableharness_kernel_v1_kernel_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RunSessionResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RunSessionResult) ProtoMessage() {} - -func (x *RunSessionResult) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_kernel_v1_kernel_proto_msgTypes[1] - 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 RunSessionResult.ProtoReflect.Descriptor instead. -func (*RunSessionResult) Descriptor() ([]byte, []int) { - return file_pluggableharness_kernel_v1_kernel_proto_rawDescGZIP(), []int{1} -} - -func (x *RunSessionResult) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -func (x *RunSessionResult) GetFinalMessage() *v11.Message { - if x != nil { - return x.FinalMessage - } - return nil -} - -func (x *RunSessionResult) GetStatus() v12.SessionStatus { - if x != nil { - return x.Status - } - return v12.SessionStatus(0) -} - -func (x *RunSessionResult) GetTotalCostUsd() float64 { - if x != nil { - return x.TotalCostUsd - } - return 0 -} - -func (x *RunSessionResult) GetTotalInputTokens() int64 { - if x != nil { - return x.TotalInputTokens - } - return 0 -} - -func (x *RunSessionResult) GetTotalOutputTokens() int64 { - if x != nil { - return x.TotalOutputTokens - } - return 0 -} - -// CountTokensRequest asks the kernel to count tokens for a block of -// content, optionally against a specific model's tokenizer. See -// kernel-callbacks.md §2. -type CountTokensRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The content to count. MUST be set. Text-only in v1, matching the - // content-type constraint context.md and memory.md already impose. - Content []*v11.ContentBlock `protobuf:"bytes,1,rep,name=content,proto3" json:"content,omitempty"` - // The model whose tokenizer should be preferred, if that model - // provider implements the optional CountTokens RPC (model.md §2.1). - // MAY be omitted, in which case the kernel's fallback heuristic - // (kernel-callbacks.md §3) is used. - ModelRef *v13.ModelRef `protobuf:"bytes,2,opt,name=model_ref,json=modelRef,proto3,oneof" json:"model_ref,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CountTokensRequest) Reset() { - *x = CountTokensRequest{} - mi := &file_pluggableharness_kernel_v1_kernel_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CountTokensRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CountTokensRequest) ProtoMessage() {} - -func (x *CountTokensRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_kernel_v1_kernel_proto_msgTypes[2] - 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 CountTokensRequest.ProtoReflect.Descriptor instead. -func (*CountTokensRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_kernel_v1_kernel_proto_rawDescGZIP(), []int{2} -} - -func (x *CountTokensRequest) GetContent() []*v11.ContentBlock { - if x != nil { - return x.Content - } - return nil -} - -func (x *CountTokensRequest) GetModelRef() *v13.ModelRef { - if x != nil { - return x.ModelRef - } - return nil -} - -// CountTokensResult carries the resolved token count and whether it came -// from a real vendor tokenizer or the kernel's fallback heuristic. -type CountTokensResult struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The resolved token count. - Count int64 `protobuf:"varint,1,opt,name=count,proto3" json:"count,omitempty"` - // MUST be set. True if a real vendor tokenizer produced this count - // (that model provider's own optional CountTokens RPC); false if the - // kernel's single documented fallback heuristic did - // (kernel-callbacks.md §3: ceil(total_utf8_byte_length(text)/4)). - Exact bool `protobuf:"varint,2,opt,name=exact,proto3" json:"exact,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CountTokensResult) Reset() { - *x = CountTokensResult{} - mi := &file_pluggableharness_kernel_v1_kernel_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CountTokensResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CountTokensResult) ProtoMessage() {} - -func (x *CountTokensResult) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_kernel_v1_kernel_proto_msgTypes[3] - 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 CountTokensResult.ProtoReflect.Descriptor instead. -func (*CountTokensResult) Descriptor() ([]byte, []int) { - return file_pluggableharness_kernel_v1_kernel_proto_rawDescGZIP(), []int{3} -} - -func (x *CountTokensResult) GetCount() int64 { - if x != nil { - return x.Count - } - return 0 -} - -func (x *CountTokensResult) GetExact() bool { - if x != nil { - return x.Exact - } - return false -} - -// EmitRequest asks the kernel to persist one event into the calling -// session's state backend. See kernel-callbacks.md §4. -type EmitRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The calling session's id. MUST be set. The kernel MUST reject an - // Emit naming any session other than the one the calling plugin was - // actually invoked for. - SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - // The kind of event being emitted. MUST be set. - Kind EventKind `protobuf:"varint,2,opt,name=kind,proto3,enum=pluggableharness.kernel.v1.EventKind" json:"kind,omitempty"` - // Versions the shape of `payload`, so a future kernel can still - // interpret an old event correctly — the "supersedes" mechanism - // described in docs/specifications/architecture.md. MUST be set. - SchemaVersion string `protobuf:"bytes,3,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` - // The event payload. MUST be set. Opaque to the kernel by design - // (state-backend.md §4.1: "kernel never inspects this"); its structure - // is defined by whichever spec owns this EventKind. This is the one - // deliberate opaque-bytes carve-out in this file — every other field - // here is strongly typed. - Payload []byte `protobuf:"bytes,4,opt,name=payload,proto3" json:"payload,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *EmitRequest) Reset() { - *x = EmitRequest{} - mi := &file_pluggableharness_kernel_v1_kernel_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *EmitRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EmitRequest) ProtoMessage() {} - -func (x *EmitRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_kernel_v1_kernel_proto_msgTypes[4] - 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 EmitRequest.ProtoReflect.Descriptor instead. -func (*EmitRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_kernel_v1_kernel_proto_rawDescGZIP(), []int{4} -} - -func (x *EmitRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -func (x *EmitRequest) GetKind() EventKind { - if x != nil { - return x.Kind - } - return EventKind_EVENT_KIND_UNSPECIFIED -} - -func (x *EmitRequest) GetSchemaVersion() string { - if x != nil { - return x.SchemaVersion - } - return "" -} - -func (x *EmitRequest) GetPayload() []byte { - if x != nil { - return x.Payload - } - return nil -} - -// EmitResult carries the identifiers the kernel assigned to a persisted -// event. -type EmitResult struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The assigned, storage-independent event id. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - // The assigned, ordering-authoritative sequence number. - Sequence int64 `protobuf:"varint,2,opt,name=sequence,proto3" json:"sequence,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *EmitResult) Reset() { - *x = EmitResult{} - mi := &file_pluggableharness_kernel_v1_kernel_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *EmitResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EmitResult) ProtoMessage() {} - -func (x *EmitResult) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_kernel_v1_kernel_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 EmitResult.ProtoReflect.Descriptor instead. -func (*EmitResult) Descriptor() ([]byte, []int) { - return file_pluggableharness_kernel_v1_kernel_proto_rawDescGZIP(), []int{5} -} - -func (x *EmitResult) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *EmitResult) GetSequence() int64 { - if x != nil { - return x.Sequence - } - return 0 -} - -// LogRequest carries one structured log entry from a plugin to the -// kernel. See kernel-callbacks.md §5. -type LogRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The session this log entry is attributable to. MAY be omitted — - // unlike EmitRequest.session_id, this is not mandatory, since logging - // can legitimately happen outside any session context (plugin startup, - // Configure-time, shutdown). - SessionId *string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3,oneof" json:"session_id,omitempty"` - // The log entry itself. MUST be set. - Entry *v14.LogEntry `protobuf:"bytes,2,opt,name=entry,proto3" json:"entry,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *LogRequest) Reset() { - *x = LogRequest{} - mi := &file_pluggableharness_kernel_v1_kernel_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *LogRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*LogRequest) ProtoMessage() {} - -func (x *LogRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_kernel_v1_kernel_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 LogRequest.ProtoReflect.Descriptor instead. -func (*LogRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_kernel_v1_kernel_proto_rawDescGZIP(), []int{6} -} - -func (x *LogRequest) GetSessionId() string { - if x != nil && x.SessionId != nil { - return *x.SessionId - } - return "" -} - -func (x *LogRequest) GetEntry() *v14.LogEntry { - if x != nil { - return x.Entry - } - return nil -} - -// LogResult is empty: a Log call either succeeds or the RPC itself -// returns a gRPC error status. There is nothing else to report back. -type LogResult struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *LogResult) Reset() { - *x = LogResult{} - mi := &file_pluggableharness_kernel_v1_kernel_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *LogResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*LogResult) ProtoMessage() {} - -func (x *LogResult) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_kernel_v1_kernel_proto_msgTypes[7] - 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 LogResult.ProtoReflect.Descriptor instead. -func (*LogResult) Descriptor() ([]byte, []int) { - return file_pluggableharness_kernel_v1_kernel_proto_rawDescGZIP(), []int{7} -} - -var File_pluggableharness_kernel_v1_kernel_proto protoreflect.FileDescriptor - -const file_pluggableharness_kernel_v1_kernel_proto_rawDesc = "" + - "\n" + - "'pluggableharness/kernel/v1/kernel.proto\x12\x1apluggableharness.kernel.v1\x1a'pluggableharness/common/v1/common.proto\x1a)pluggableharness/content/v1/content.proto\x1a!pluggableharness/log/v1/log.proto\x1a%pluggableharness/model/v1/model.proto\x1a)pluggableharness/session/v1/session.proto\"\xa9\x02\n" + - "\x11RunSessionRequest\x12\x18\n" + - "\aprofile\x18\x01 \x01(\tR\aprofile\x12\x16\n" + - "\x06prompt\x18\x02 \x01(\tR\x06prompt\x12*\n" + - "\x11parent_session_id\x18\x03 \x01(\tR\x0fparentSessionId\x12'\n" + - "\x0fremaining_depth\x18\x04 \x01(\x05R\x0eremainingDepth\x129\n" + - "\x19remaining_cost_budget_usd\x18\x05 \x01(\x01R\x16remainingCostBudgetUsd\x12R\n" + - "\x10scoped_providers\x18\x06 \x03(\v2'.pluggableharness.common.v1.ProviderRefR\x0fscopedProviders\"\xc4\x02\n" + - "\x10RunSessionResult\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\x12I\n" + - "\rfinal_message\x18\x02 \x01(\v2$.pluggableharness.content.v1.MessageR\ffinalMessage\x12B\n" + - "\x06status\x18\x03 \x01(\x0e2*.pluggableharness.session.v1.SessionStatusR\x06status\x12$\n" + - "\x0etotal_cost_usd\x18\x04 \x01(\x01R\ftotalCostUsd\x12,\n" + - "\x12total_input_tokens\x18\x05 \x01(\x03R\x10totalInputTokens\x12.\n" + - "\x13total_output_tokens\x18\x06 \x01(\x03R\x11totalOutputTokens\"\xae\x01\n" + - "\x12CountTokensRequest\x12C\n" + - "\acontent\x18\x01 \x03(\v2).pluggableharness.content.v1.ContentBlockR\acontent\x12E\n" + - "\tmodel_ref\x18\x02 \x01(\v2#.pluggableharness.model.v1.ModelRefH\x00R\bmodelRef\x88\x01\x01B\f\n" + - "\n" + - "_model_ref\"?\n" + - "\x11CountTokensResult\x12\x14\n" + - "\x05count\x18\x01 \x01(\x03R\x05count\x12\x14\n" + - "\x05exact\x18\x02 \x01(\bR\x05exact\"\xa8\x01\n" + - "\vEmitRequest\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\x129\n" + - "\x04kind\x18\x02 \x01(\x0e2%.pluggableharness.kernel.v1.EventKindR\x04kind\x12%\n" + - "\x0eschema_version\x18\x03 \x01(\tR\rschemaVersion\x12\x18\n" + - "\apayload\x18\x04 \x01(\fR\apayload\"8\n" + - "\n" + - "EmitResult\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12\x1a\n" + - "\bsequence\x18\x02 \x01(\x03R\bsequence\"x\n" + - "\n" + - "LogRequest\x12\"\n" + - "\n" + - "session_id\x18\x01 \x01(\tH\x00R\tsessionId\x88\x01\x01\x127\n" + - "\x05entry\x18\x02 \x01(\v2!.pluggableharness.log.v1.LogEntryR\x05entryB\r\n" + - "\v_session_id\"\v\n" + - "\tLogResult*\xb9\x02\n" + - "\tEventKind\x12\x1a\n" + - "\x16EVENT_KIND_UNSPECIFIED\x10\x00\x12\x16\n" + - "\x12EVENT_KIND_MESSAGE\x10\x01\x12\x18\n" + - "\x14EVENT_KIND_TOOL_CALL\x10\x02\x12\x1a\n" + - "\x16EVENT_KIND_TOOL_RESULT\x10\x03\x12\x13\n" + - "\x0fEVENT_KIND_PLAN\x10\x04\x12\x14\n" + - "\x10EVENT_KIND_APPLY\x10\x05\x12#\n" + - "\x1fEVENT_KIND_CONTEXT_CONTRIBUTION\x10\x06\x12\x1b\n" + - "\x17EVENT_KIND_MEMORY_WRITE\x10\a\x12\x1c\n" + - "\x18EVENT_KIND_MEMORY_UPDATE\x10\b\x12\x1c\n" + - "\x18EVENT_KIND_MEMORY_DELETE\x10\t\x12\x19\n" + - "\x15EVENT_KIND_HOOK_ERROR\x10\n" + - "2\x9f\x03\n" + - "\x15KernelCallbackService\x12i\n" + - "\n" + - "RunSession\x12-.pluggableharness.kernel.v1.RunSessionRequest\x1a,.pluggableharness.kernel.v1.RunSessionResult\x12l\n" + - "\vCountTokens\x12..pluggableharness.kernel.v1.CountTokensRequest\x1a-.pluggableharness.kernel.v1.CountTokensResult\x12W\n" + - "\x04Emit\x12'.pluggableharness.kernel.v1.EmitRequest\x1a&.pluggableharness.kernel.v1.EmitResult\x12T\n" + - "\x03Log\x12&.pluggableharness.kernel.v1.LogRequest\x1a%.pluggableharness.kernel.v1.LogResultB@Z>github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1b\x06proto3" - -var ( - file_pluggableharness_kernel_v1_kernel_proto_rawDescOnce sync.Once - file_pluggableharness_kernel_v1_kernel_proto_rawDescData []byte -) - -func file_pluggableharness_kernel_v1_kernel_proto_rawDescGZIP() []byte { - file_pluggableharness_kernel_v1_kernel_proto_rawDescOnce.Do(func() { - file_pluggableharness_kernel_v1_kernel_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_kernel_v1_kernel_proto_rawDesc), len(file_pluggableharness_kernel_v1_kernel_proto_rawDesc))) - }) - return file_pluggableharness_kernel_v1_kernel_proto_rawDescData -} - -var file_pluggableharness_kernel_v1_kernel_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_pluggableharness_kernel_v1_kernel_proto_msgTypes = make([]protoimpl.MessageInfo, 8) -var file_pluggableharness_kernel_v1_kernel_proto_goTypes = []any{ - (EventKind)(0), // 0: pluggableharness.kernel.v1.EventKind - (*RunSessionRequest)(nil), // 1: pluggableharness.kernel.v1.RunSessionRequest - (*RunSessionResult)(nil), // 2: pluggableharness.kernel.v1.RunSessionResult - (*CountTokensRequest)(nil), // 3: pluggableharness.kernel.v1.CountTokensRequest - (*CountTokensResult)(nil), // 4: pluggableharness.kernel.v1.CountTokensResult - (*EmitRequest)(nil), // 5: pluggableharness.kernel.v1.EmitRequest - (*EmitResult)(nil), // 6: pluggableharness.kernel.v1.EmitResult - (*LogRequest)(nil), // 7: pluggableharness.kernel.v1.LogRequest - (*LogResult)(nil), // 8: pluggableharness.kernel.v1.LogResult - (*v1.ProviderRef)(nil), // 9: pluggableharness.common.v1.ProviderRef - (*v11.Message)(nil), // 10: pluggableharness.content.v1.Message - (v12.SessionStatus)(0), // 11: pluggableharness.session.v1.SessionStatus - (*v11.ContentBlock)(nil), // 12: pluggableharness.content.v1.ContentBlock - (*v13.ModelRef)(nil), // 13: pluggableharness.model.v1.ModelRef - (*v14.LogEntry)(nil), // 14: pluggableharness.log.v1.LogEntry -} -var file_pluggableharness_kernel_v1_kernel_proto_depIdxs = []int32{ - 9, // 0: pluggableharness.kernel.v1.RunSessionRequest.scoped_providers:type_name -> pluggableharness.common.v1.ProviderRef - 10, // 1: pluggableharness.kernel.v1.RunSessionResult.final_message:type_name -> pluggableharness.content.v1.Message - 11, // 2: pluggableharness.kernel.v1.RunSessionResult.status:type_name -> pluggableharness.session.v1.SessionStatus - 12, // 3: pluggableharness.kernel.v1.CountTokensRequest.content:type_name -> pluggableharness.content.v1.ContentBlock - 13, // 4: pluggableharness.kernel.v1.CountTokensRequest.model_ref:type_name -> pluggableharness.model.v1.ModelRef - 0, // 5: pluggableharness.kernel.v1.EmitRequest.kind:type_name -> pluggableharness.kernel.v1.EventKind - 14, // 6: pluggableharness.kernel.v1.LogRequest.entry:type_name -> pluggableharness.log.v1.LogEntry - 1, // 7: pluggableharness.kernel.v1.KernelCallbackService.RunSession:input_type -> pluggableharness.kernel.v1.RunSessionRequest - 3, // 8: pluggableharness.kernel.v1.KernelCallbackService.CountTokens:input_type -> pluggableharness.kernel.v1.CountTokensRequest - 5, // 9: pluggableharness.kernel.v1.KernelCallbackService.Emit:input_type -> pluggableharness.kernel.v1.EmitRequest - 7, // 10: pluggableharness.kernel.v1.KernelCallbackService.Log:input_type -> pluggableharness.kernel.v1.LogRequest - 2, // 11: pluggableharness.kernel.v1.KernelCallbackService.RunSession:output_type -> pluggableharness.kernel.v1.RunSessionResult - 4, // 12: pluggableharness.kernel.v1.KernelCallbackService.CountTokens:output_type -> pluggableharness.kernel.v1.CountTokensResult - 6, // 13: pluggableharness.kernel.v1.KernelCallbackService.Emit:output_type -> pluggableharness.kernel.v1.EmitResult - 8, // 14: pluggableharness.kernel.v1.KernelCallbackService.Log:output_type -> pluggableharness.kernel.v1.LogResult - 11, // [11:15] is the sub-list for method output_type - 7, // [7:11] 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 -} - -func init() { file_pluggableharness_kernel_v1_kernel_proto_init() } -func file_pluggableharness_kernel_v1_kernel_proto_init() { - if File_pluggableharness_kernel_v1_kernel_proto != nil { - return - } - file_pluggableharness_kernel_v1_kernel_proto_msgTypes[2].OneofWrappers = []any{} - file_pluggableharness_kernel_v1_kernel_proto_msgTypes[6].OneofWrappers = []any{} - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_kernel_v1_kernel_proto_rawDesc), len(file_pluggableharness_kernel_v1_kernel_proto_rawDesc)), - NumEnums: 1, - NumMessages: 8, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_pluggableharness_kernel_v1_kernel_proto_goTypes, - DependencyIndexes: file_pluggableharness_kernel_v1_kernel_proto_depIdxs, - EnumInfos: file_pluggableharness_kernel_v1_kernel_proto_enumTypes, - MessageInfos: file_pluggableharness_kernel_v1_kernel_proto_msgTypes, - }.Build() - File_pluggableharness_kernel_v1_kernel_proto = out.File - file_pluggableharness_kernel_v1_kernel_proto_goTypes = nil - file_pluggableharness_kernel_v1_kernel_proto_depIdxs = nil -} diff --git a/pkg/kernel/proto/v1/kernel_grpc.pb.go b/pkg/kernel/proto/v1/kernel_grpc.pb.go deleted file mode 100644 index 827e213..0000000 --- a/pkg/kernel/proto/v1/kernel_grpc.pb.go +++ /dev/null @@ -1,346 +0,0 @@ -// Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// versions: -// - protoc-gen-go-grpc v1.6.2 -// - protoc (unknown) -// source: pluggableharness/kernel/v1/kernel.proto - -// Package pluggableharness.kernel.v1 defines the kernel-callback service described -// in specifications/kernel-callbacks.md (RunSession, CountTokens, Emit, -// Log) — the plugin-to-kernel calling direction every plugin category gets -// at handshake, the reverse of every other category's protocol in this -// series. Unlike a category plugin protocol, this service carries no -// GetCapabilities/Configure RPCs: it isn't something the kernel dials into -// a plugin, it's the connection every plugin subprocess is handed back to -// call into the kernel. - -package kernelv1 - -import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" -) - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.64.0 or later. -const _ = grpc.SupportPackageIsVersion9 - -const ( - KernelCallbackService_RunSession_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/RunSession" - KernelCallbackService_CountTokens_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/CountTokens" - KernelCallbackService_Emit_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/Emit" - KernelCallbackService_Log_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/Log" -) - -// KernelCallbackServiceClient is the client API for KernelCallbackService service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -// -// KernelCallbackService is the plugin-to-kernel callback channel described -// in specifications/kernel-callbacks.md §1. hashicorp/go-plugin natively -// supports bidirectional plugins, and this is that mechanism: every plugin -// subprocess, for every category defined across this series, MUST be given -// a client connection to this service at handshake time, unconditionally. -// A plugin that never calls back simply never uses it, but the channel's -// presence is not gated on category — a context provider needing -// CountTokens is just as valid a caller as a tool provider needing -// RunSession. -type KernelCallbackServiceClient interface { - // RunSession dispatches a nested sub-agent session under a named - // agent.hcl profile. Full semantics — profile resolution, budget - // inheritance, visibility of intermediate turns — are defined in - // agent-loop.md §7 and are not repeated here; kernel-callbacks.md §1 - // gives this RPC's calling contract. - // - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "RunSessionResult", the exact name agent-loop.md §7.1 - // uses in its own data-type definition. Not a uniqueness violation: - // RunSessionResult is used by exactly this one RPC. - RunSession(ctx context.Context, in *RunSessionRequest, opts ...grpc.CallOption) (*RunSessionResult, error) - // CountTokens resolves the token-counting gap independently flagged by - // context.md §12, configuration.md §12, memory.md §13, and frontend.md - // §10: exactly one kernel-owned implementation, so that `tokens` figures - // produced by different providers stay mutually comparable and additive - // for configuration.md §6's budget-sum arithmetic. See - // kernel-callbacks.md §2 for the resolution algorithm and §3 for the - // single documented fallback formula. - // - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "CountTokensResult", the exact name kernel-callbacks.md - // §2 uses. Not a uniqueness violation: used by exactly this one RPC. - CountTokens(ctx context.Context, in *CountTokensRequest, opts ...grpc.CallOption) (*CountTokensResult, error) - // Emit is how a plugin persists anything into the session's state - // backend. The kernel is the state backend's sole writer - // (state-backend.md §3) — a plugin never opens or writes the sqlite file - // directly; it calls Emit and the kernel performs the actual write, - // assigning the ordering-authoritative sequence number and the stable - // event id itself. See kernel-callbacks.md §4. - // - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "EmitResult", the exact name kernel-callbacks.md §4 - // uses. Not a uniqueness violation: used by exactly this one RPC. - Emit(ctx context.Context, in *EmitRequest, opts ...grpc.CallOption) (*EmitResult, error) - // Log carries a plugin's own log output into the kernel's centralized - // logging, so it doesn't vanish into an unread subprocess stderr. - // Unlike Emit, a Log call is not tied to an active session — a plugin - // MAY call Log before any session exists (process startup, or from - // within Configure) or after one has ended (during shutdown). See - // kernel-callbacks.md §5. - // - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "LogResult", the exact name kernel-callbacks.md §5 - // uses. Not a uniqueness violation: used by exactly this one RPC. - Log(ctx context.Context, in *LogRequest, opts ...grpc.CallOption) (*LogResult, error) -} - -type kernelCallbackServiceClient struct { - cc grpc.ClientConnInterface -} - -func NewKernelCallbackServiceClient(cc grpc.ClientConnInterface) KernelCallbackServiceClient { - return &kernelCallbackServiceClient{cc} -} - -func (c *kernelCallbackServiceClient) RunSession(ctx context.Context, in *RunSessionRequest, opts ...grpc.CallOption) (*RunSessionResult, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(RunSessionResult) - err := c.cc.Invoke(ctx, KernelCallbackService_RunSession_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *kernelCallbackServiceClient) CountTokens(ctx context.Context, in *CountTokensRequest, opts ...grpc.CallOption) (*CountTokensResult, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(CountTokensResult) - err := c.cc.Invoke(ctx, KernelCallbackService_CountTokens_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *kernelCallbackServiceClient) Emit(ctx context.Context, in *EmitRequest, opts ...grpc.CallOption) (*EmitResult, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(EmitResult) - err := c.cc.Invoke(ctx, KernelCallbackService_Emit_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *kernelCallbackServiceClient) Log(ctx context.Context, in *LogRequest, opts ...grpc.CallOption) (*LogResult, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(LogResult) - err := c.cc.Invoke(ctx, KernelCallbackService_Log_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -// KernelCallbackServiceServer is the server API for KernelCallbackService service. -// All implementations must embed UnimplementedKernelCallbackServiceServer -// for forward compatibility. -// -// KernelCallbackService is the plugin-to-kernel callback channel described -// in specifications/kernel-callbacks.md §1. hashicorp/go-plugin natively -// supports bidirectional plugins, and this is that mechanism: every plugin -// subprocess, for every category defined across this series, MUST be given -// a client connection to this service at handshake time, unconditionally. -// A plugin that never calls back simply never uses it, but the channel's -// presence is not gated on category — a context provider needing -// CountTokens is just as valid a caller as a tool provider needing -// RunSession. -type KernelCallbackServiceServer interface { - // RunSession dispatches a nested sub-agent session under a named - // agent.hcl profile. Full semantics — profile resolution, budget - // inheritance, visibility of intermediate turns — are defined in - // agent-loop.md §7 and are not repeated here; kernel-callbacks.md §1 - // gives this RPC's calling contract. - // - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "RunSessionResult", the exact name agent-loop.md §7.1 - // uses in its own data-type definition. Not a uniqueness violation: - // RunSessionResult is used by exactly this one RPC. - RunSession(context.Context, *RunSessionRequest) (*RunSessionResult, error) - // CountTokens resolves the token-counting gap independently flagged by - // context.md §12, configuration.md §12, memory.md §13, and frontend.md - // §10: exactly one kernel-owned implementation, so that `tokens` figures - // produced by different providers stay mutually comparable and additive - // for configuration.md §6's budget-sum arithmetic. See - // kernel-callbacks.md §2 for the resolution algorithm and §3 for the - // single documented fallback formula. - // - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "CountTokensResult", the exact name kernel-callbacks.md - // §2 uses. Not a uniqueness violation: used by exactly this one RPC. - CountTokens(context.Context, *CountTokensRequest) (*CountTokensResult, error) - // Emit is how a plugin persists anything into the session's state - // backend. The kernel is the state backend's sole writer - // (state-backend.md §3) — a plugin never opens or writes the sqlite file - // directly; it calls Emit and the kernel performs the actual write, - // assigning the ordering-authoritative sequence number and the stable - // event id itself. See kernel-callbacks.md §4. - // - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "EmitResult", the exact name kernel-callbacks.md §4 - // uses. Not a uniqueness violation: used by exactly this one RPC. - Emit(context.Context, *EmitRequest) (*EmitResult, error) - // Log carries a plugin's own log output into the kernel's centralized - // logging, so it doesn't vanish into an unread subprocess stderr. - // Unlike Emit, a Log call is not tied to an active session — a plugin - // MAY call Log before any session exists (process startup, or from - // within Configure) or after one has ended (during shutdown). See - // kernel-callbacks.md §5. - // - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "LogResult", the exact name kernel-callbacks.md §5 - // uses. Not a uniqueness violation: used by exactly this one RPC. - Log(context.Context, *LogRequest) (*LogResult, error) - mustEmbedUnimplementedKernelCallbackServiceServer() -} - -// UnimplementedKernelCallbackServiceServer must be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedKernelCallbackServiceServer struct{} - -func (UnimplementedKernelCallbackServiceServer) RunSession(context.Context, *RunSessionRequest) (*RunSessionResult, error) { - return nil, status.Error(codes.Unimplemented, "method RunSession not implemented") -} -func (UnimplementedKernelCallbackServiceServer) CountTokens(context.Context, *CountTokensRequest) (*CountTokensResult, error) { - return nil, status.Error(codes.Unimplemented, "method CountTokens not implemented") -} -func (UnimplementedKernelCallbackServiceServer) Emit(context.Context, *EmitRequest) (*EmitResult, error) { - return nil, status.Error(codes.Unimplemented, "method Emit not implemented") -} -func (UnimplementedKernelCallbackServiceServer) Log(context.Context, *LogRequest) (*LogResult, error) { - return nil, status.Error(codes.Unimplemented, "method Log not implemented") -} -func (UnimplementedKernelCallbackServiceServer) mustEmbedUnimplementedKernelCallbackServiceServer() {} -func (UnimplementedKernelCallbackServiceServer) testEmbeddedByValue() {} - -// UnsafeKernelCallbackServiceServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to KernelCallbackServiceServer will -// result in compilation errors. -type UnsafeKernelCallbackServiceServer interface { - mustEmbedUnimplementedKernelCallbackServiceServer() -} - -func RegisterKernelCallbackServiceServer(s grpc.ServiceRegistrar, srv KernelCallbackServiceServer) { - // If the following call panics, it indicates UnimplementedKernelCallbackServiceServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } - s.RegisterService(&KernelCallbackService_ServiceDesc, srv) -} - -func _KernelCallbackService_RunSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(RunSessionRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(KernelCallbackServiceServer).RunSession(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: KernelCallbackService_RunSession_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(KernelCallbackServiceServer).RunSession(ctx, req.(*RunSessionRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _KernelCallbackService_CountTokens_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(CountTokensRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(KernelCallbackServiceServer).CountTokens(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: KernelCallbackService_CountTokens_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(KernelCallbackServiceServer).CountTokens(ctx, req.(*CountTokensRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _KernelCallbackService_Emit_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(EmitRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(KernelCallbackServiceServer).Emit(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: KernelCallbackService_Emit_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(KernelCallbackServiceServer).Emit(ctx, req.(*EmitRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _KernelCallbackService_Log_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(LogRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(KernelCallbackServiceServer).Log(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: KernelCallbackService_Log_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(KernelCallbackServiceServer).Log(ctx, req.(*LogRequest)) - } - return interceptor(ctx, in, info, handler) -} - -// KernelCallbackService_ServiceDesc is the grpc.ServiceDesc for KernelCallbackService service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var KernelCallbackService_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "pluggableharness.kernel.v1.KernelCallbackService", - HandlerType: (*KernelCallbackServiceServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "RunSession", - Handler: _KernelCallbackService_RunSession_Handler, - }, - { - MethodName: "CountTokens", - Handler: _KernelCallbackService_CountTokens_Handler, - }, - { - MethodName: "Emit", - Handler: _KernelCallbackService_Emit_Handler, - }, - { - MethodName: "Log", - Handler: _KernelCallbackService_Log_Handler, - }, - }, - Streams: []grpc.StreamDesc{}, - Metadata: "pluggableharness/kernel/v1/kernel.proto", -} diff --git a/pkg/kernel/proto/v1/rpc_request.pb.go b/pkg/kernel/proto/v1/rpc_request.pb.go new file mode 100644 index 0000000..5ddea56 --- /dev/null +++ b/pkg/kernel/proto/v1/rpc_request.pb.go @@ -0,0 +1,941 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/kernel/v1/rpc_request.proto + +package kernelv1 + +import ( + v1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + v11 "github.com/pluggableharness/agent/pkg/content/proto/v1" + v13 "github.com/pluggableharness/agent/pkg/log/proto/v1" + v15 "github.com/pluggableharness/agent/pkg/metric/proto/v1" + v12 "github.com/pluggableharness/agent/pkg/model/proto/v1" + v14 "github.com/pluggableharness/agent/pkg/trace/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// RunSessionRequest names the sub-agent profile to dispatch and carries +// the inherited, only-shrinking resource budgets the child session is +// bound by. See agent-loop.md §7. +type RunSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Named sub-agent profile from agent.hcl to run this session under. + // MUST be set. + Profile string `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"` + // The prompt to run the sub-agent session with. + Prompt string `protobuf:"bytes,2,opt,name=prompt,proto3" json:"prompt,omitempty"` + // The calling session's id. MUST be set to the id of the session making + // this RunSession call, establishing the parent/child relationship the + // state backend and replay model rely on. + ParentSessionId string `protobuf:"bytes,3,opt,name=parent_session_id,json=parentSessionId,proto3" json:"parent_session_id,omitempty"` + // Remaining sub-agent nesting depth available to the child session. + // MUST be set. An inherited, only-shrinking budget computed by the + // kernel per configuration.md §8.4 / agent-loop.md §7.5 — the child is + // never able to widen it, only spend down what it was given. + RemainingDepth int32 `protobuf:"varint,4,opt,name=remaining_depth,json=remainingDepth,proto3" json:"remaining_depth,omitempty"` + // Remaining cost budget, in USD, available to the child session. MUST + // be set. Same inherited, only-shrinking shape as remaining_depth, + // computed per agent-loop.md §3.1. + RemainingCostBudgetUsd float64 `protobuf:"fixed64,5,opt,name=remaining_cost_budget_usd,json=remainingCostBudgetUsd,proto3" json:"remaining_cost_budget_usd,omitempty"` + // The set of providers the child session is scoped to, resolved from + // the named profile's declared tool set. A caller MAY narrow this + // further per-call but MUST NOT widen it beyond what the profile + // declares. + ScopedProviders []*v1.ProviderRef `protobuf:"bytes,6,rep,name=scoped_providers,json=scopedProviders,proto3" json:"scoped_providers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RunSessionRequest) Reset() { + *x = RunSessionRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RunSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RunSessionRequest) ProtoMessage() {} + +func (x *RunSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[0] + 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 RunSessionRequest.ProtoReflect.Descriptor instead. +func (*RunSessionRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{0} +} + +func (x *RunSessionRequest) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +func (x *RunSessionRequest) GetPrompt() string { + if x != nil { + return x.Prompt + } + return "" +} + +func (x *RunSessionRequest) GetParentSessionId() string { + if x != nil { + return x.ParentSessionId + } + return "" +} + +func (x *RunSessionRequest) GetRemainingDepth() int32 { + if x != nil { + return x.RemainingDepth + } + return 0 +} + +func (x *RunSessionRequest) GetRemainingCostBudgetUsd() float64 { + if x != nil { + return x.RemainingCostBudgetUsd + } + return 0 +} + +func (x *RunSessionRequest) GetScopedProviders() []*v1.ProviderRef { + if x != nil { + return x.ScopedProviders + } + return nil +} + +// CountTokensRequest asks the kernel to count tokens for a block of +// content, optionally against a specific model's tokenizer. See +// kernel-callbacks.md §2. +type CountTokensRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The content to count. MUST be set. Text-only in v1, matching the + // content-type constraint context.md and memory.md already impose. + Content []*v11.ContentBlock `protobuf:"bytes,1,rep,name=content,proto3" json:"content,omitempty"` + // The model whose tokenizer should be preferred, if that model + // provider implements the optional CountTokens RPC (model.md §2.1). + // MAY be omitted, in which case the kernel's fallback heuristic + // (kernel-callbacks.md §3) is used. + ModelRef *v12.ModelRef `protobuf:"bytes,2,opt,name=model_ref,json=modelRef,proto3,oneof" json:"model_ref,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CountTokensRequest) Reset() { + *x = CountTokensRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CountTokensRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CountTokensRequest) ProtoMessage() {} + +func (x *CountTokensRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[1] + 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 CountTokensRequest.ProtoReflect.Descriptor instead. +func (*CountTokensRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{1} +} + +func (x *CountTokensRequest) GetContent() []*v11.ContentBlock { + if x != nil { + return x.Content + } + return nil +} + +func (x *CountTokensRequest) GetModelRef() *v12.ModelRef { + if x != nil { + return x.ModelRef + } + return nil +} + +// EmitRequest asks the kernel to persist one event into the calling +// session's state backend. See kernel-callbacks.md §4. +type EmitRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The calling session's id. MUST be set. The kernel MUST reject an + // Emit naming any session other than the one the calling plugin was + // actually invoked for. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // The kind of event being emitted. MUST be set. + Kind EventKind `protobuf:"varint,2,opt,name=kind,proto3,enum=pluggableharness.kernel.v1.EventKind" json:"kind,omitempty"` + // Versions the shape of `payload`, so a future kernel can still + // interpret an old event correctly — the "supersedes" mechanism + // described in docs/specifications/architecture.md. MUST be set. + SchemaVersion string `protobuf:"bytes,3,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` + // The event payload. MUST be set. Opaque to the kernel by design + // (state-backend.md §4.1: "kernel never inspects this"); its structure + // is defined by whichever spec owns this EventKind. One of two + // deliberate opaque-bytes carve-outs in this file — see PublishRequest + // below for the other — every other field in either message is + // strongly typed. + Payload []byte `protobuf:"bytes,4,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EmitRequest) Reset() { + *x = EmitRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EmitRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EmitRequest) ProtoMessage() {} + +func (x *EmitRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[2] + 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 EmitRequest.ProtoReflect.Descriptor instead. +func (*EmitRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{2} +} + +func (x *EmitRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *EmitRequest) GetKind() EventKind { + if x != nil { + return x.Kind + } + return EventKind_EVENT_KIND_UNSPECIFIED +} + +func (x *EmitRequest) GetSchemaVersion() string { + if x != nil { + return x.SchemaVersion + } + return "" +} + +func (x *EmitRequest) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +// LogRequest carries a batch of structured log entries from a plugin to +// the kernel. See kernel-callbacks.md §5. Batched rather than one entry +// per call: a plugin logging at TRACE would otherwise pay one unary +// round-trip per line. +type LogRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session this log entry is attributable to. MAY be omitted — + // unlike EmitRequest.session_id, this is not mandatory, since logging + // can legitimately happen outside any session context (plugin startup, + // Configure-time, shutdown). + SessionId *string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3,oneof" json:"session_id,omitempty"` + // The batch of log entries, in the order the plugin produced them. + // MUST be non-empty. A malformed entry within an otherwise-valid batch + // is skipped and warned about individually, not treated as failing the + // whole call — see kernel-callbacks.md §5. + Entries []*v13.LogEntry `protobuf:"bytes,3,rep,name=entries,proto3" json:"entries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LogRequest) Reset() { + *x = LogRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LogRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LogRequest) ProtoMessage() {} + +func (x *LogRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[3] + 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 LogRequest.ProtoReflect.Descriptor instead. +func (*LogRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{3} +} + +func (x *LogRequest) GetSessionId() string { + if x != nil && x.SessionId != nil { + return *x.SessionId + } + return "" +} + +func (x *LogRequest) GetEntries() []*v13.LogEntry { + if x != nil { + return x.Entries + } + return nil +} + +// ExportSpansRequest relays a batch of a plugin's own completed trace +// spans to the kernel for forwarding to the operator's configured +// collector. See kernel-callbacks.md's ExportSpans and +// observability.md#the-relay-model. +type ExportSpansRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session this batch is attributable to, when any. MAY be + // omitted — same session-optional rule as LogRequest.session_id, since + // a plugin may produce spans outside any session context. + SessionId *string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3,oneof" json:"session_id,omitempty"` + // The batch of completed spans. MUST be non-empty. The kernel MUST NOT + // alter any span's identity or timing fields before relaying it. + Spans []*v14.Span `protobuf:"bytes,2,rep,name=spans,proto3" json:"spans,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExportSpansRequest) Reset() { + *x = ExportSpansRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExportSpansRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExportSpansRequest) ProtoMessage() {} + +func (x *ExportSpansRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[4] + 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 ExportSpansRequest.ProtoReflect.Descriptor instead. +func (*ExportSpansRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{4} +} + +func (x *ExportSpansRequest) GetSessionId() string { + if x != nil && x.SessionId != nil { + return *x.SessionId + } + return "" +} + +func (x *ExportSpansRequest) GetSpans() []*v14.Span { + if x != nil { + return x.Spans + } + return nil +} + +// RecordMetricsRequest relays a batch of metric observations. See +// kernel-callbacks.md's RecordMetrics and +// observability.md#the-tracing-metrics-asymmetry for why this is not a +// transparent relay the way ExportSpansRequest is. +type RecordMetricsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session this batch is attributable to, when any. MAY be + // omitted — same rule as LogRequest.session_id. + SessionId *string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3,oneof" json:"session_id,omitempty"` + // The batch of metric observations. MUST be non-empty. + Metrics []*v15.MetricRecord `protobuf:"bytes,2,rep,name=metrics,proto3" json:"metrics,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecordMetricsRequest) Reset() { + *x = RecordMetricsRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecordMetricsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecordMetricsRequest) ProtoMessage() {} + +func (x *RecordMetricsRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_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 RecordMetricsRequest.ProtoReflect.Descriptor instead. +func (*RecordMetricsRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{5} +} + +func (x *RecordMetricsRequest) GetSessionId() string { + if x != nil && x.SessionId != nil { + return *x.SessionId + } + return "" +} + +func (x *RecordMetricsRequest) GetMetrics() []*v15.MetricRecord { + if x != nil { + return x.Metrics + } + return nil +} + +// GetTelemetryConfigRequest asks the kernel whether tracing/metrics/logs +// are enabled and at what level/ratio. See kernel-callbacks.md's +// GetTelemetryConfig. Empty: the caller's identity comes from the +// callback connection, never a request field. +type GetTelemetryConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTelemetryConfigRequest) Reset() { + *x = GetTelemetryConfigRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTelemetryConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTelemetryConfigRequest) ProtoMessage() {} + +func (x *GetTelemetryConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_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 GetTelemetryConfigRequest.ProtoReflect.Descriptor instead. +func (*GetTelemetryConfigRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{6} +} + +// GetConfigRequest asks the kernel for the calling plugin's own resolved +// agent.hcl configuration. See kernel-callbacks.md's GetConfig. Empty: +// the caller's identity comes from the callback connection, never a +// request field. +type GetConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetConfigRequest) Reset() { + *x = GetConfigRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetConfigRequest) ProtoMessage() {} + +func (x *GetConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[7] + 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 GetConfigRequest.ProtoReflect.Descriptor instead. +func (*GetConfigRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{7} +} + +// PublishRequest emits one event onto the event bus. See +// kernel-callbacks.md's Publish and event-bus.md. +type PublishRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // A single dot-free, wildcard-free segment naming this occurrence + // within the plugin's own namespace, e.g. "file_changed". MUST be set. + // The kernel MUST reject a value containing "." or "*". + EventType string `protobuf:"bytes,1,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"` + // The event payload. MAY be empty. Opaque to the kernel by design — + // the second of two deliberate opaque-bytes carve-outs in this + // package (see EmitRequest.payload above); a third-party plugin's own + // event shape can't be named by this proto ahead of time. + Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` + // Identifies payload's shape for a subscriber: a fully-qualified proto + // message name (preferred) or a media type. MUST be set. + PayloadType string `protobuf:"bytes,3,opt,name=payload_type,json=payloadType,proto3" json:"payload_type,omitempty"` + // Versions payload_type the same way EmitRequest.schema_version + // versions Emit's payload. MUST be set. + SchemaVersion string `protobuf:"bytes,4,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PublishRequest) Reset() { + *x = PublishRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PublishRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PublishRequest) ProtoMessage() {} + +func (x *PublishRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[8] + 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 PublishRequest.ProtoReflect.Descriptor instead. +func (*PublishRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{8} +} + +func (x *PublishRequest) GetEventType() string { + if x != nil { + return x.EventType + } + return "" +} + +func (x *PublishRequest) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +func (x *PublishRequest) GetPayloadType() string { + if x != nil { + return x.PayloadType + } + return "" +} + +func (x *PublishRequest) GetSchemaVersion() string { + if x != nil { + return x.SchemaVersion + } + return "" +} + +// SubscribeRequest opens a server-streaming subscription to the event +// bus. See kernel-callbacks.md's Subscribe and event-bus.md#filter-grammar. +type SubscribeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The topics to receive events for. MUST be non-empty. Each entry is + // either an exact topic or a topic prefix ending in "*" + // (event-bus.md#filter-grammar). No other wildcard form is valid in + // v1. + TopicFilters []string `protobuf:"bytes,1,rep,name=topic_filters,json=topicFilters,proto3" json:"topic_filters,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeRequest) Reset() { + *x = SubscribeRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubscribeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeRequest) ProtoMessage() {} + +func (x *SubscribeRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[9] + 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 SubscribeRequest.ProtoReflect.Descriptor instead. +func (*SubscribeRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{9} +} + +func (x *SubscribeRequest) GetTopicFilters() []string { + if x != nil { + return x.TopicFilters + } + return nil +} + +// ReadEventsRequest asks the kernel to read back the calling plugin's own +// session's persisted event log, ordered by sequence. See +// kernel-callbacks.md's ReadEvents. +type ReadEventsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The calling session's id. MUST be set. Same one-session-only rule as + // EmitRequest.session_id — the kernel MUST reject a call naming any + // other session. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // Restricts the stream to these kinds. MAY be empty, meaning every + // kind. + Kinds []EventKind `protobuf:"varint,2,rep,packed,name=kinds,proto3,enum=pluggableharness.kernel.v1.EventKind" json:"kinds,omitempty"` + // Resume point: only events with sequence >= this value are streamed. + // MAY be omitted, meaning from the start of the session's log. + FromSequence *int64 `protobuf:"varint,3,opt,name=from_sequence,json=fromSequence,proto3,oneof" json:"from_sequence,omitempty"` + // Caps the number of events streamed. MAY be omitted, meaning no + // limit. + Limit *int32 `protobuf:"varint,4,opt,name=limit,proto3,oneof" json:"limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReadEventsRequest) Reset() { + *x = ReadEventsRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReadEventsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadEventsRequest) ProtoMessage() {} + +func (x *ReadEventsRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[10] + 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 ReadEventsRequest.ProtoReflect.Descriptor instead. +func (*ReadEventsRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{10} +} + +func (x *ReadEventsRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *ReadEventsRequest) GetKinds() []EventKind { + if x != nil { + return x.Kinds + } + return nil +} + +func (x *ReadEventsRequest) GetFromSequence() int64 { + if x != nil && x.FromSequence != nil { + return *x.FromSequence + } + return 0 +} + +func (x *ReadEventsRequest) GetLimit() int32 { + if x != nil && x.Limit != nil { + return *x.Limit + } + return 0 +} + +// GetSessionRequest asks the kernel for the calling plugin's own +// session's metadata and live budget rollups. See kernel-callbacks.md's +// GetSession. +type GetSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The calling session's id. MUST be set. Same one-session-only rule as + // EmitRequest.session_id. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSessionRequest) Reset() { + *x = GetSessionRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSessionRequest) ProtoMessage() {} + +func (x *GetSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[11] + 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 GetSessionRequest.ProtoReflect.Descriptor instead. +func (*GetSessionRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{11} +} + +func (x *GetSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +var File_pluggableharness_kernel_v1_rpc_request_proto protoreflect.FileDescriptor + +const file_pluggableharness_kernel_v1_rpc_request_proto_rawDesc = "" + + "\n" + + ",pluggableharness/kernel/v1/rpc_request.proto\x12\x1apluggableharness.kernel.v1\x1a&pluggableharness/common/v1/types.proto\x1a'pluggableharness/content/v1/types.proto\x1a&pluggableharness/kernel/v1/types.proto\x1a#pluggableharness/log/v1/types.proto\x1a&pluggableharness/metric/v1/types.proto\x1a%pluggableharness/model/v1/types.proto\x1a%pluggableharness/trace/v1/types.proto\"\xa9\x02\n" + + "\x11RunSessionRequest\x12\x18\n" + + "\aprofile\x18\x01 \x01(\tR\aprofile\x12\x16\n" + + "\x06prompt\x18\x02 \x01(\tR\x06prompt\x12*\n" + + "\x11parent_session_id\x18\x03 \x01(\tR\x0fparentSessionId\x12'\n" + + "\x0fremaining_depth\x18\x04 \x01(\x05R\x0eremainingDepth\x129\n" + + "\x19remaining_cost_budget_usd\x18\x05 \x01(\x01R\x16remainingCostBudgetUsd\x12R\n" + + "\x10scoped_providers\x18\x06 \x03(\v2'.pluggableharness.common.v1.ProviderRefR\x0fscopedProviders\"\xae\x01\n" + + "\x12CountTokensRequest\x12C\n" + + "\acontent\x18\x01 \x03(\v2).pluggableharness.content.v1.ContentBlockR\acontent\x12E\n" + + "\tmodel_ref\x18\x02 \x01(\v2#.pluggableharness.model.v1.ModelRefH\x00R\bmodelRef\x88\x01\x01B\f\n" + + "\n" + + "_model_ref\"\xa8\x01\n" + + "\vEmitRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x129\n" + + "\x04kind\x18\x02 \x01(\x0e2%.pluggableharness.kernel.v1.EventKindR\x04kind\x12%\n" + + "\x0eschema_version\x18\x03 \x01(\tR\rschemaVersion\x12\x18\n" + + "\apayload\x18\x04 \x01(\fR\apayload\"\x89\x01\n" + + "\n" + + "LogRequest\x12\"\n" + + "\n" + + "session_id\x18\x01 \x01(\tH\x00R\tsessionId\x88\x01\x01\x12;\n" + + "\aentries\x18\x03 \x03(\v2!.pluggableharness.log.v1.LogEntryR\aentriesB\r\n" + + "\v_session_idJ\x04\b\x02\x10\x03R\x05entry\"~\n" + + "\x12ExportSpansRequest\x12\"\n" + + "\n" + + "session_id\x18\x01 \x01(\tH\x00R\tsessionId\x88\x01\x01\x125\n" + + "\x05spans\x18\x02 \x03(\v2\x1f.pluggableharness.trace.v1.SpanR\x05spansB\r\n" + + "\v_session_id\"\x8d\x01\n" + + "\x14RecordMetricsRequest\x12\"\n" + + "\n" + + "session_id\x18\x01 \x01(\tH\x00R\tsessionId\x88\x01\x01\x12B\n" + + "\ametrics\x18\x02 \x03(\v2(.pluggableharness.metric.v1.MetricRecordR\ametricsB\r\n" + + "\v_session_id\"\x1b\n" + + "\x19GetTelemetryConfigRequest\"\x12\n" + + "\x10GetConfigRequest\"\x93\x01\n" + + "\x0ePublishRequest\x12\x1d\n" + + "\n" + + "event_type\x18\x01 \x01(\tR\teventType\x12\x18\n" + + "\apayload\x18\x02 \x01(\fR\apayload\x12!\n" + + "\fpayload_type\x18\x03 \x01(\tR\vpayloadType\x12%\n" + + "\x0eschema_version\x18\x04 \x01(\tR\rschemaVersion\"7\n" + + "\x10SubscribeRequest\x12#\n" + + "\rtopic_filters\x18\x01 \x03(\tR\ftopicFilters\"\xd0\x01\n" + + "\x11ReadEventsRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12;\n" + + "\x05kinds\x18\x02 \x03(\x0e2%.pluggableharness.kernel.v1.EventKindR\x05kinds\x12(\n" + + "\rfrom_sequence\x18\x03 \x01(\x03H\x00R\ffromSequence\x88\x01\x01\x12\x19\n" + + "\x05limit\x18\x04 \x01(\x05H\x01R\x05limit\x88\x01\x01B\x10\n" + + "\x0e_from_sequenceB\b\n" + + "\x06_limit\"2\n" + + "\x11GetSessionRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionIdB@Z>github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1b\x06proto3" + +var ( + file_pluggableharness_kernel_v1_rpc_request_proto_rawDescOnce sync.Once + file_pluggableharness_kernel_v1_rpc_request_proto_rawDescData []byte +) + +func file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP() []byte { + file_pluggableharness_kernel_v1_rpc_request_proto_rawDescOnce.Do(func() { + file_pluggableharness_kernel_v1_rpc_request_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_kernel_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_kernel_v1_rpc_request_proto_rawDesc))) + }) + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescData +} + +var file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_pluggableharness_kernel_v1_rpc_request_proto_goTypes = []any{ + (*RunSessionRequest)(nil), // 0: pluggableharness.kernel.v1.RunSessionRequest + (*CountTokensRequest)(nil), // 1: pluggableharness.kernel.v1.CountTokensRequest + (*EmitRequest)(nil), // 2: pluggableharness.kernel.v1.EmitRequest + (*LogRequest)(nil), // 3: pluggableharness.kernel.v1.LogRequest + (*ExportSpansRequest)(nil), // 4: pluggableharness.kernel.v1.ExportSpansRequest + (*RecordMetricsRequest)(nil), // 5: pluggableharness.kernel.v1.RecordMetricsRequest + (*GetTelemetryConfigRequest)(nil), // 6: pluggableharness.kernel.v1.GetTelemetryConfigRequest + (*GetConfigRequest)(nil), // 7: pluggableharness.kernel.v1.GetConfigRequest + (*PublishRequest)(nil), // 8: pluggableharness.kernel.v1.PublishRequest + (*SubscribeRequest)(nil), // 9: pluggableharness.kernel.v1.SubscribeRequest + (*ReadEventsRequest)(nil), // 10: pluggableharness.kernel.v1.ReadEventsRequest + (*GetSessionRequest)(nil), // 11: pluggableharness.kernel.v1.GetSessionRequest + (*v1.ProviderRef)(nil), // 12: pluggableharness.common.v1.ProviderRef + (*v11.ContentBlock)(nil), // 13: pluggableharness.content.v1.ContentBlock + (*v12.ModelRef)(nil), // 14: pluggableharness.model.v1.ModelRef + (EventKind)(0), // 15: pluggableharness.kernel.v1.EventKind + (*v13.LogEntry)(nil), // 16: pluggableharness.log.v1.LogEntry + (*v14.Span)(nil), // 17: pluggableharness.trace.v1.Span + (*v15.MetricRecord)(nil), // 18: pluggableharness.metric.v1.MetricRecord +} +var file_pluggableharness_kernel_v1_rpc_request_proto_depIdxs = []int32{ + 12, // 0: pluggableharness.kernel.v1.RunSessionRequest.scoped_providers:type_name -> pluggableharness.common.v1.ProviderRef + 13, // 1: pluggableharness.kernel.v1.CountTokensRequest.content:type_name -> pluggableharness.content.v1.ContentBlock + 14, // 2: pluggableharness.kernel.v1.CountTokensRequest.model_ref:type_name -> pluggableharness.model.v1.ModelRef + 15, // 3: pluggableharness.kernel.v1.EmitRequest.kind:type_name -> pluggableharness.kernel.v1.EventKind + 16, // 4: pluggableharness.kernel.v1.LogRequest.entries:type_name -> pluggableharness.log.v1.LogEntry + 17, // 5: pluggableharness.kernel.v1.ExportSpansRequest.spans:type_name -> pluggableharness.trace.v1.Span + 18, // 6: pluggableharness.kernel.v1.RecordMetricsRequest.metrics:type_name -> pluggableharness.metric.v1.MetricRecord + 15, // 7: pluggableharness.kernel.v1.ReadEventsRequest.kinds:type_name -> pluggableharness.kernel.v1.EventKind + 8, // [8:8] is the sub-list for method output_type + 8, // [8:8] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name +} + +func init() { file_pluggableharness_kernel_v1_rpc_request_proto_init() } +func file_pluggableharness_kernel_v1_rpc_request_proto_init() { + if File_pluggableharness_kernel_v1_rpc_request_proto != nil { + return + } + file_pluggableharness_kernel_v1_types_proto_init() + file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[1].OneofWrappers = []any{} + file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[3].OneofWrappers = []any{} + file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[4].OneofWrappers = []any{} + file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[5].OneofWrappers = []any{} + file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[10].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_kernel_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_kernel_v1_rpc_request_proto_rawDesc)), + NumEnums: 0, + NumMessages: 12, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_kernel_v1_rpc_request_proto_goTypes, + DependencyIndexes: file_pluggableharness_kernel_v1_rpc_request_proto_depIdxs, + MessageInfos: file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes, + }.Build() + File_pluggableharness_kernel_v1_rpc_request_proto = out.File + file_pluggableharness_kernel_v1_rpc_request_proto_goTypes = nil + file_pluggableharness_kernel_v1_rpc_request_proto_depIdxs = nil +} diff --git a/pkg/kernel/proto/v1/rpc_response.pb.go b/pkg/kernel/proto/v1/rpc_response.pb.go new file mode 100644 index 0000000..33b8b7b --- /dev/null +++ b/pkg/kernel/proto/v1/rpc_response.pb.go @@ -0,0 +1,728 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/kernel/v1/rpc_response.proto + +package kernelv1 + +import ( + v1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + v12 "github.com/pluggableharness/agent/pkg/log/proto/v1" + v11 "github.com/pluggableharness/agent/pkg/session/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// RunSessionResult carries the child session's outcome back to the +// calling plugin once the child has reached a terminal state. +type RunSessionResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The id of the child session that was created and run. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // The child session's final message — the only thing that crosses the + // session boundary back to the parent turn. Intermediate turns + // produced by the child are never visible to the parent's model + // context (agent-loop.md §7.2), though they remain queryable in the + // state backend for replay and audit. + FinalMessage *v1.Message `protobuf:"bytes,2,opt,name=final_message,json=finalMessage,proto3" json:"final_message,omitempty"` + // The child session's terminal lifecycle status. Never + // SESSION_STATUS_RUNNING — a RunSession call only returns once the + // child session has reached a terminal state. + Status v11.SessionStatus `protobuf:"varint,3,opt,name=status,proto3,enum=pluggableharness.session.v1.SessionStatus" json:"status,omitempty"` + // The child session's aggregate cost, in USD, summed across every turn + // it ran, including any of its own descendant sub-agent sessions. MUST + // be set. Deliberately a flat field, not a + // pluggableharness.model.v1.Usage-shaped reference — this is a + // whole-session rollup (state-backend.md's cost_ledger SUM, per + // .claude/rules/determinism.md's "Cost and budget rollup"), a different + // shape from one completion call's per-call Usage. Lets an orchestrator + // plugin do budget-aware fan-out (spend-check a child's outcome before + // deciding whether to spawn another) without needing to separately sum + // the child's own event history itself. + TotalCostUsd float64 `protobuf:"fixed64,4,opt,name=total_cost_usd,json=totalCostUsd,proto3" json:"total_cost_usd,omitempty"` + // The child session's aggregate input token count, summed across every + // turn it ran, including descendants. MUST be set. Same flat, + // aggregate-not-per-call rationale as total_cost_usd. + TotalInputTokens int64 `protobuf:"varint,5,opt,name=total_input_tokens,json=totalInputTokens,proto3" json:"total_input_tokens,omitempty"` + // The child session's aggregate output token count, summed across + // every turn it ran, including descendants. MUST be set. Same flat, + // aggregate-not-per-call rationale as total_cost_usd. + TotalOutputTokens int64 `protobuf:"varint,6,opt,name=total_output_tokens,json=totalOutputTokens,proto3" json:"total_output_tokens,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RunSessionResult) Reset() { + *x = RunSessionResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RunSessionResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RunSessionResult) ProtoMessage() {} + +func (x *RunSessionResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[0] + 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 RunSessionResult.ProtoReflect.Descriptor instead. +func (*RunSessionResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{0} +} + +func (x *RunSessionResult) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *RunSessionResult) GetFinalMessage() *v1.Message { + if x != nil { + return x.FinalMessage + } + return nil +} + +func (x *RunSessionResult) GetStatus() v11.SessionStatus { + if x != nil { + return x.Status + } + return v11.SessionStatus(0) +} + +func (x *RunSessionResult) GetTotalCostUsd() float64 { + if x != nil { + return x.TotalCostUsd + } + return 0 +} + +func (x *RunSessionResult) GetTotalInputTokens() int64 { + if x != nil { + return x.TotalInputTokens + } + return 0 +} + +func (x *RunSessionResult) GetTotalOutputTokens() int64 { + if x != nil { + return x.TotalOutputTokens + } + return 0 +} + +// CountTokensResult carries the resolved token count and whether it came +// from a real vendor tokenizer or the kernel's fallback heuristic. +type CountTokensResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The resolved token count. + Count int64 `protobuf:"varint,1,opt,name=count,proto3" json:"count,omitempty"` + // MUST be set. True if a real vendor tokenizer produced this count + // (that model provider's own optional CountTokens RPC); false if the + // kernel's single documented fallback heuristic did + // (kernel-callbacks.md §3: ceil(total_utf8_byte_length(text)/4)). + Exact bool `protobuf:"varint,2,opt,name=exact,proto3" json:"exact,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CountTokensResult) Reset() { + *x = CountTokensResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CountTokensResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CountTokensResult) ProtoMessage() {} + +func (x *CountTokensResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[1] + 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 CountTokensResult.ProtoReflect.Descriptor instead. +func (*CountTokensResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{1} +} + +func (x *CountTokensResult) GetCount() int64 { + if x != nil { + return x.Count + } + return 0 +} + +func (x *CountTokensResult) GetExact() bool { + if x != nil { + return x.Exact + } + return false +} + +// EmitResult carries the identifiers the kernel assigned to a persisted +// event. +type EmitResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The assigned, storage-independent event id. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // The assigned, ordering-authoritative sequence number. + Sequence int64 `protobuf:"varint,2,opt,name=sequence,proto3" json:"sequence,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EmitResult) Reset() { + *x = EmitResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EmitResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EmitResult) ProtoMessage() {} + +func (x *EmitResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[2] + 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 EmitResult.ProtoReflect.Descriptor instead. +func (*EmitResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{2} +} + +func (x *EmitResult) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *EmitResult) GetSequence() int64 { + if x != nil { + return x.Sequence + } + return 0 +} + +// LogResult is empty: a Log call either succeeds or the RPC itself +// returns a gRPC error status. There is nothing else to report back. +type LogResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LogResult) Reset() { + *x = LogResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LogResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LogResult) ProtoMessage() {} + +func (x *LogResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[3] + 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 LogResult.ProtoReflect.Descriptor instead. +func (*LogResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{3} +} + +// ExportSpansResult is empty, the same shape as LogResult and for the +// same reason. +type ExportSpansResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExportSpansResult) Reset() { + *x = ExportSpansResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExportSpansResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExportSpansResult) ProtoMessage() {} + +func (x *ExportSpansResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[4] + 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 ExportSpansResult.ProtoReflect.Descriptor instead. +func (*ExportSpansResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{4} +} + +// RecordMetricsResult is empty, the same shape as LogResult and for the +// same reason. +type RecordMetricsResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecordMetricsResult) Reset() { + *x = RecordMetricsResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecordMetricsResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecordMetricsResult) ProtoMessage() {} + +func (x *RecordMetricsResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_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 RecordMetricsResult.ProtoReflect.Descriptor instead. +func (*RecordMetricsResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{5} +} + +// GetTelemetryConfigResult carries the operator's current tracing/ +// metrics/logs configuration. See kernel-callbacks.md's +// GetTelemetryConfig. +type GetTelemetryConfigResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Whether trace export is on. MUST be set. + TracesEnabled bool `protobuf:"varint,1,opt,name=traces_enabled,json=tracesEnabled,proto3" json:"traces_enabled,omitempty"` + // Whether metrics export is on. MUST be set. + MetricsEnabled bool `protobuf:"varint,2,opt,name=metrics_enabled,json=metricsEnabled,proto3" json:"metrics_enabled,omitempty"` + // Whether log export is on. MUST be set. + LogsEnabled bool `protobuf:"varint,3,opt,name=logs_enabled,json=logsEnabled,proto3" json:"logs_enabled,omitempty"` + // The floor below which a Log entry is accepted but immediately + // discarded kernel-side. MUST be set. See kernel-callbacks.md's Log + // section. + LogLevel v12.LogLevel `protobuf:"varint,4,opt,name=log_level,json=logLevel,proto3,enum=pluggableharness.log.v1.LogLevel" json:"log_level,omitempty"` + // The configured ParentBased(TraceIDRatioBased) sampler ratio. MUST be + // set; meaningful only when traces_enabled. + SamplingRatio float64 `protobuf:"fixed64,5,opt,name=sampling_ratio,json=samplingRatio,proto3" json:"sampling_ratio,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTelemetryConfigResult) Reset() { + *x = GetTelemetryConfigResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTelemetryConfigResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTelemetryConfigResult) ProtoMessage() {} + +func (x *GetTelemetryConfigResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_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 GetTelemetryConfigResult.ProtoReflect.Descriptor instead. +func (*GetTelemetryConfigResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{6} +} + +func (x *GetTelemetryConfigResult) GetTracesEnabled() bool { + if x != nil { + return x.TracesEnabled + } + return false +} + +func (x *GetTelemetryConfigResult) GetMetricsEnabled() bool { + if x != nil { + return x.MetricsEnabled + } + return false +} + +func (x *GetTelemetryConfigResult) GetLogsEnabled() bool { + if x != nil { + return x.LogsEnabled + } + return false +} + +func (x *GetTelemetryConfigResult) GetLogLevel() v12.LogLevel { + if x != nil { + return x.LogLevel + } + return v12.LogLevel(0) +} + +func (x *GetTelemetryConfigResult) GetSamplingRatio() float64 { + if x != nil { + return x.SamplingRatio + } + return 0 +} + +// GetConfigResult carries the calling plugin's own already-decoded +// agent.hcl configuration. See kernel-callbacks.md's GetConfig. +type GetConfigResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The plugin's resolved config, identical in shape to what its own + // ConfigureRequest.config carried at Configure time. MUST be set. + // Secrets are already resolved through the schema-to-cty bridge — see + // kernel-callbacks.md's GetConfig for the MUST NOT-echo rule this + // implies. + Config *structpb.Struct `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetConfigResult) Reset() { + *x = GetConfigResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetConfigResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetConfigResult) ProtoMessage() {} + +func (x *GetConfigResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[7] + 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 GetConfigResult.ProtoReflect.Descriptor instead. +func (*GetConfigResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{7} +} + +func (x *GetConfigResult) GetConfig() *structpb.Struct { + if x != nil { + return x.Config + } + return nil +} + +// PublishResult carries the fully-resolved topic an event was published +// on. See kernel-callbacks.md's Publish. +type PublishResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The topic this event was published on: + // "plugin.{category}.{name}.{event_type}". See + // event-bus.md#topic-grammar. + Topic string `protobuf:"bytes,1,opt,name=topic,proto3" json:"topic,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PublishResult) Reset() { + *x = PublishResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PublishResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PublishResult) ProtoMessage() {} + +func (x *PublishResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[8] + 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 PublishResult.ProtoReflect.Descriptor instead. +func (*PublishResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{8} +} + +func (x *PublishResult) GetTopic() string { + if x != nil { + return x.Topic + } + return "" +} + +// GetSessionResult carries the calling plugin's own session's metadata +// plus its live, in-memory budget rollups. See kernel-callbacks.md's +// GetSession. +type GetSessionResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session's persisted metadata and cost rollup. MUST be set. + Info *v11.SessionInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"` + // The session's remaining sub-agent nesting depth. MUST be set. Live, + // in-memory kernel state — never persisted (state-backend.md's + // live-vs-post-hoc distinction) — not a value read back from the state + // backend the way info.cost_usd is. + RemainingDepth int32 `protobuf:"varint,2,opt,name=remaining_depth,json=remainingDepth,proto3" json:"remaining_depth,omitempty"` + // The session's remaining cost budget, in USD. MUST be set. Same + // live, in-memory rationale as remaining_depth. + RemainingCostBudgetUsd float64 `protobuf:"fixed64,3,opt,name=remaining_cost_budget_usd,json=remainingCostBudgetUsd,proto3" json:"remaining_cost_budget_usd,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSessionResult) Reset() { + *x = GetSessionResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSessionResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSessionResult) ProtoMessage() {} + +func (x *GetSessionResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[9] + 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 GetSessionResult.ProtoReflect.Descriptor instead. +func (*GetSessionResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{9} +} + +func (x *GetSessionResult) GetInfo() *v11.SessionInfo { + if x != nil { + return x.Info + } + return nil +} + +func (x *GetSessionResult) GetRemainingDepth() int32 { + if x != nil { + return x.RemainingDepth + } + return 0 +} + +func (x *GetSessionResult) GetRemainingCostBudgetUsd() float64 { + if x != nil { + return x.RemainingCostBudgetUsd + } + return 0 +} + +var File_pluggableharness_kernel_v1_rpc_response_proto protoreflect.FileDescriptor + +const file_pluggableharness_kernel_v1_rpc_response_proto_rawDesc = "" + + "\n" + + "-pluggableharness/kernel/v1/rpc_response.proto\x12\x1apluggableharness.kernel.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a'pluggableharness/content/v1/types.proto\x1a#pluggableharness/log/v1/types.proto\x1a'pluggableharness/session/v1/types.proto\"\xc4\x02\n" + + "\x10RunSessionResult\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12I\n" + + "\rfinal_message\x18\x02 \x01(\v2$.pluggableharness.content.v1.MessageR\ffinalMessage\x12B\n" + + "\x06status\x18\x03 \x01(\x0e2*.pluggableharness.session.v1.SessionStatusR\x06status\x12$\n" + + "\x0etotal_cost_usd\x18\x04 \x01(\x01R\ftotalCostUsd\x12,\n" + + "\x12total_input_tokens\x18\x05 \x01(\x03R\x10totalInputTokens\x12.\n" + + "\x13total_output_tokens\x18\x06 \x01(\x03R\x11totalOutputTokens\"?\n" + + "\x11CountTokensResult\x12\x14\n" + + "\x05count\x18\x01 \x01(\x03R\x05count\x12\x14\n" + + "\x05exact\x18\x02 \x01(\bR\x05exact\"8\n" + + "\n" + + "EmitResult\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1a\n" + + "\bsequence\x18\x02 \x01(\x03R\bsequence\"\v\n" + + "\tLogResult\"\x13\n" + + "\x11ExportSpansResult\"\x15\n" + + "\x13RecordMetricsResult\"\xf4\x01\n" + + "\x18GetTelemetryConfigResult\x12%\n" + + "\x0etraces_enabled\x18\x01 \x01(\bR\rtracesEnabled\x12'\n" + + "\x0fmetrics_enabled\x18\x02 \x01(\bR\x0emetricsEnabled\x12!\n" + + "\flogs_enabled\x18\x03 \x01(\bR\vlogsEnabled\x12>\n" + + "\tlog_level\x18\x04 \x01(\x0e2!.pluggableharness.log.v1.LogLevelR\blogLevel\x12%\n" + + "\x0esampling_ratio\x18\x05 \x01(\x01R\rsamplingRatio\"B\n" + + "\x0fGetConfigResult\x12/\n" + + "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06config\"%\n" + + "\rPublishResult\x12\x14\n" + + "\x05topic\x18\x01 \x01(\tR\x05topic\"\xb4\x01\n" + + "\x10GetSessionResult\x12<\n" + + "\x04info\x18\x01 \x01(\v2(.pluggableharness.session.v1.SessionInfoR\x04info\x12'\n" + + "\x0fremaining_depth\x18\x02 \x01(\x05R\x0eremainingDepth\x129\n" + + "\x19remaining_cost_budget_usd\x18\x03 \x01(\x01R\x16remainingCostBudgetUsdB@Z>github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1b\x06proto3" + +var ( + file_pluggableharness_kernel_v1_rpc_response_proto_rawDescOnce sync.Once + file_pluggableharness_kernel_v1_rpc_response_proto_rawDescData []byte +) + +func file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP() []byte { + file_pluggableharness_kernel_v1_rpc_response_proto_rawDescOnce.Do(func() { + file_pluggableharness_kernel_v1_rpc_response_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_kernel_v1_rpc_response_proto_rawDesc), len(file_pluggableharness_kernel_v1_rpc_response_proto_rawDesc))) + }) + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescData +} + +var file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_pluggableharness_kernel_v1_rpc_response_proto_goTypes = []any{ + (*RunSessionResult)(nil), // 0: pluggableharness.kernel.v1.RunSessionResult + (*CountTokensResult)(nil), // 1: pluggableharness.kernel.v1.CountTokensResult + (*EmitResult)(nil), // 2: pluggableharness.kernel.v1.EmitResult + (*LogResult)(nil), // 3: pluggableharness.kernel.v1.LogResult + (*ExportSpansResult)(nil), // 4: pluggableharness.kernel.v1.ExportSpansResult + (*RecordMetricsResult)(nil), // 5: pluggableharness.kernel.v1.RecordMetricsResult + (*GetTelemetryConfigResult)(nil), // 6: pluggableharness.kernel.v1.GetTelemetryConfigResult + (*GetConfigResult)(nil), // 7: pluggableharness.kernel.v1.GetConfigResult + (*PublishResult)(nil), // 8: pluggableharness.kernel.v1.PublishResult + (*GetSessionResult)(nil), // 9: pluggableharness.kernel.v1.GetSessionResult + (*v1.Message)(nil), // 10: pluggableharness.content.v1.Message + (v11.SessionStatus)(0), // 11: pluggableharness.session.v1.SessionStatus + (v12.LogLevel)(0), // 12: pluggableharness.log.v1.LogLevel + (*structpb.Struct)(nil), // 13: google.protobuf.Struct + (*v11.SessionInfo)(nil), // 14: pluggableharness.session.v1.SessionInfo +} +var file_pluggableharness_kernel_v1_rpc_response_proto_depIdxs = []int32{ + 10, // 0: pluggableharness.kernel.v1.RunSessionResult.final_message:type_name -> pluggableharness.content.v1.Message + 11, // 1: pluggableharness.kernel.v1.RunSessionResult.status:type_name -> pluggableharness.session.v1.SessionStatus + 12, // 2: pluggableharness.kernel.v1.GetTelemetryConfigResult.log_level:type_name -> pluggableharness.log.v1.LogLevel + 13, // 3: pluggableharness.kernel.v1.GetConfigResult.config:type_name -> google.protobuf.Struct + 14, // 4: pluggableharness.kernel.v1.GetSessionResult.info:type_name -> pluggableharness.session.v1.SessionInfo + 5, // [5:5] is the sub-list for method output_type + 5, // [5:5] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_pluggableharness_kernel_v1_rpc_response_proto_init() } +func file_pluggableharness_kernel_v1_rpc_response_proto_init() { + if File_pluggableharness_kernel_v1_rpc_response_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_kernel_v1_rpc_response_proto_rawDesc), len(file_pluggableharness_kernel_v1_rpc_response_proto_rawDesc)), + NumEnums: 0, + NumMessages: 10, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_kernel_v1_rpc_response_proto_goTypes, + DependencyIndexes: file_pluggableharness_kernel_v1_rpc_response_proto_depIdxs, + MessageInfos: file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes, + }.Build() + File_pluggableharness_kernel_v1_rpc_response_proto = out.File + file_pluggableharness_kernel_v1_rpc_response_proto_goTypes = nil + file_pluggableharness_kernel_v1_rpc_response_proto_depIdxs = nil +} diff --git a/pkg/kernel/proto/v1/service.pb.go b/pkg/kernel/proto/v1/service.pb.go new file mode 100644 index 0000000..828b049 --- /dev/null +++ b/pkg/kernel/proto/v1/service.pb.go @@ -0,0 +1,138 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/kernel/v1/service.proto + +// Package pluggableharness.kernel.v1 defines the kernel-callback service described +// in specifications/kernel-callbacks.md (RunSession, CountTokens, Emit, +// Log, ExportSpans, RecordMetrics, GetTelemetryConfig, GetConfig, Publish, +// Subscribe, ReadEvents, GetSession) — the plugin-to-kernel calling +// direction every plugin category gets at handshake, the reverse of every +// other category's protocol in this series. Unlike a category plugin +// protocol, this service carries no GetCapabilities/Configure RPCs: it +// isn't something the kernel dials into a plugin, it's the connection +// every plugin subprocess is handed back to call into the kernel. + +package kernelv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_pluggableharness_kernel_v1_service_proto protoreflect.FileDescriptor + +const file_pluggableharness_kernel_v1_service_proto_rawDesc = "" + + "\n" + + "(pluggableharness/kernel/v1/service.proto\x12\x1apluggableharness.kernel.v1\x1a'pluggableharness/kernel/v1/events.proto\x1a,pluggableharness/kernel/v1/rpc_request.proto\x1a-pluggableharness/kernel/v1/rpc_response.proto2\x85\n" + + "\n" + + "\x15KernelCallbackService\x12i\n" + + "\n" + + "RunSession\x12-.pluggableharness.kernel.v1.RunSessionRequest\x1a,.pluggableharness.kernel.v1.RunSessionResult\x12l\n" + + "\vCountTokens\x12..pluggableharness.kernel.v1.CountTokensRequest\x1a-.pluggableharness.kernel.v1.CountTokensResult\x12W\n" + + "\x04Emit\x12'.pluggableharness.kernel.v1.EmitRequest\x1a&.pluggableharness.kernel.v1.EmitResult\x12T\n" + + "\x03Log\x12&.pluggableharness.kernel.v1.LogRequest\x1a%.pluggableharness.kernel.v1.LogResult\x12l\n" + + "\vExportSpans\x12..pluggableharness.kernel.v1.ExportSpansRequest\x1a-.pluggableharness.kernel.v1.ExportSpansResult\x12r\n" + + "\rRecordMetrics\x120.pluggableharness.kernel.v1.RecordMetricsRequest\x1a/.pluggableharness.kernel.v1.RecordMetricsResult\x12\x81\x01\n" + + "\x12GetTelemetryConfig\x125.pluggableharness.kernel.v1.GetTelemetryConfigRequest\x1a4.pluggableharness.kernel.v1.GetTelemetryConfigResult\x12f\n" + + "\tGetConfig\x12,.pluggableharness.kernel.v1.GetConfigRequest\x1a+.pluggableharness.kernel.v1.GetConfigResult\x12`\n" + + "\aPublish\x12*.pluggableharness.kernel.v1.PublishRequest\x1a).pluggableharness.kernel.v1.PublishResult\x12a\n" + + "\tSubscribe\x12,.pluggableharness.kernel.v1.SubscribeRequest\x1a$.pluggableharness.kernel.v1.BusEvent0\x01\x12f\n" + + "\n" + + "ReadEvents\x12-.pluggableharness.kernel.v1.ReadEventsRequest\x1a'.pluggableharness.kernel.v1.StoredEvent0\x01\x12i\n" + + "\n" + + "GetSession\x12-.pluggableharness.kernel.v1.GetSessionRequest\x1a,.pluggableharness.kernel.v1.GetSessionResultB@Z>github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1b\x06proto3" + +var file_pluggableharness_kernel_v1_service_proto_goTypes = []any{ + (*RunSessionRequest)(nil), // 0: pluggableharness.kernel.v1.RunSessionRequest + (*CountTokensRequest)(nil), // 1: pluggableharness.kernel.v1.CountTokensRequest + (*EmitRequest)(nil), // 2: pluggableharness.kernel.v1.EmitRequest + (*LogRequest)(nil), // 3: pluggableharness.kernel.v1.LogRequest + (*ExportSpansRequest)(nil), // 4: pluggableharness.kernel.v1.ExportSpansRequest + (*RecordMetricsRequest)(nil), // 5: pluggableharness.kernel.v1.RecordMetricsRequest + (*GetTelemetryConfigRequest)(nil), // 6: pluggableharness.kernel.v1.GetTelemetryConfigRequest + (*GetConfigRequest)(nil), // 7: pluggableharness.kernel.v1.GetConfigRequest + (*PublishRequest)(nil), // 8: pluggableharness.kernel.v1.PublishRequest + (*SubscribeRequest)(nil), // 9: pluggableharness.kernel.v1.SubscribeRequest + (*ReadEventsRequest)(nil), // 10: pluggableharness.kernel.v1.ReadEventsRequest + (*GetSessionRequest)(nil), // 11: pluggableharness.kernel.v1.GetSessionRequest + (*RunSessionResult)(nil), // 12: pluggableharness.kernel.v1.RunSessionResult + (*CountTokensResult)(nil), // 13: pluggableharness.kernel.v1.CountTokensResult + (*EmitResult)(nil), // 14: pluggableharness.kernel.v1.EmitResult + (*LogResult)(nil), // 15: pluggableharness.kernel.v1.LogResult + (*ExportSpansResult)(nil), // 16: pluggableharness.kernel.v1.ExportSpansResult + (*RecordMetricsResult)(nil), // 17: pluggableharness.kernel.v1.RecordMetricsResult + (*GetTelemetryConfigResult)(nil), // 18: pluggableharness.kernel.v1.GetTelemetryConfigResult + (*GetConfigResult)(nil), // 19: pluggableharness.kernel.v1.GetConfigResult + (*PublishResult)(nil), // 20: pluggableharness.kernel.v1.PublishResult + (*BusEvent)(nil), // 21: pluggableharness.kernel.v1.BusEvent + (*StoredEvent)(nil), // 22: pluggableharness.kernel.v1.StoredEvent + (*GetSessionResult)(nil), // 23: pluggableharness.kernel.v1.GetSessionResult +} +var file_pluggableharness_kernel_v1_service_proto_depIdxs = []int32{ + 0, // 0: pluggableharness.kernel.v1.KernelCallbackService.RunSession:input_type -> pluggableharness.kernel.v1.RunSessionRequest + 1, // 1: pluggableharness.kernel.v1.KernelCallbackService.CountTokens:input_type -> pluggableharness.kernel.v1.CountTokensRequest + 2, // 2: pluggableharness.kernel.v1.KernelCallbackService.Emit:input_type -> pluggableharness.kernel.v1.EmitRequest + 3, // 3: pluggableharness.kernel.v1.KernelCallbackService.Log:input_type -> pluggableharness.kernel.v1.LogRequest + 4, // 4: pluggableharness.kernel.v1.KernelCallbackService.ExportSpans:input_type -> pluggableharness.kernel.v1.ExportSpansRequest + 5, // 5: pluggableharness.kernel.v1.KernelCallbackService.RecordMetrics:input_type -> pluggableharness.kernel.v1.RecordMetricsRequest + 6, // 6: pluggableharness.kernel.v1.KernelCallbackService.GetTelemetryConfig:input_type -> pluggableharness.kernel.v1.GetTelemetryConfigRequest + 7, // 7: pluggableharness.kernel.v1.KernelCallbackService.GetConfig:input_type -> pluggableharness.kernel.v1.GetConfigRequest + 8, // 8: pluggableharness.kernel.v1.KernelCallbackService.Publish:input_type -> pluggableharness.kernel.v1.PublishRequest + 9, // 9: pluggableharness.kernel.v1.KernelCallbackService.Subscribe:input_type -> pluggableharness.kernel.v1.SubscribeRequest + 10, // 10: pluggableharness.kernel.v1.KernelCallbackService.ReadEvents:input_type -> pluggableharness.kernel.v1.ReadEventsRequest + 11, // 11: pluggableharness.kernel.v1.KernelCallbackService.GetSession:input_type -> pluggableharness.kernel.v1.GetSessionRequest + 12, // 12: pluggableharness.kernel.v1.KernelCallbackService.RunSession:output_type -> pluggableharness.kernel.v1.RunSessionResult + 13, // 13: pluggableharness.kernel.v1.KernelCallbackService.CountTokens:output_type -> pluggableharness.kernel.v1.CountTokensResult + 14, // 14: pluggableharness.kernel.v1.KernelCallbackService.Emit:output_type -> pluggableharness.kernel.v1.EmitResult + 15, // 15: pluggableharness.kernel.v1.KernelCallbackService.Log:output_type -> pluggableharness.kernel.v1.LogResult + 16, // 16: pluggableharness.kernel.v1.KernelCallbackService.ExportSpans:output_type -> pluggableharness.kernel.v1.ExportSpansResult + 17, // 17: pluggableharness.kernel.v1.KernelCallbackService.RecordMetrics:output_type -> pluggableharness.kernel.v1.RecordMetricsResult + 18, // 18: pluggableharness.kernel.v1.KernelCallbackService.GetTelemetryConfig:output_type -> pluggableharness.kernel.v1.GetTelemetryConfigResult + 19, // 19: pluggableharness.kernel.v1.KernelCallbackService.GetConfig:output_type -> pluggableharness.kernel.v1.GetConfigResult + 20, // 20: pluggableharness.kernel.v1.KernelCallbackService.Publish:output_type -> pluggableharness.kernel.v1.PublishResult + 21, // 21: pluggableharness.kernel.v1.KernelCallbackService.Subscribe:output_type -> pluggableharness.kernel.v1.BusEvent + 22, // 22: pluggableharness.kernel.v1.KernelCallbackService.ReadEvents:output_type -> pluggableharness.kernel.v1.StoredEvent + 23, // 23: pluggableharness.kernel.v1.KernelCallbackService.GetSession:output_type -> pluggableharness.kernel.v1.GetSessionResult + 12, // [12:24] is the sub-list for method output_type + 0, // [0:12] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_pluggableharness_kernel_v1_service_proto_init() } +func file_pluggableharness_kernel_v1_service_proto_init() { + if File_pluggableharness_kernel_v1_service_proto != nil { + return + } + file_pluggableharness_kernel_v1_events_proto_init() + file_pluggableharness_kernel_v1_rpc_request_proto_init() + file_pluggableharness_kernel_v1_rpc_response_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_kernel_v1_service_proto_rawDesc), len(file_pluggableharness_kernel_v1_service_proto_rawDesc)), + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_pluggableharness_kernel_v1_service_proto_goTypes, + DependencyIndexes: file_pluggableharness_kernel_v1_service_proto_depIdxs, + }.Build() + File_pluggableharness_kernel_v1_service_proto = out.File + file_pluggableharness_kernel_v1_service_proto_goTypes = nil + file_pluggableharness_kernel_v1_service_proto_depIdxs = nil +} diff --git a/pkg/kernel/proto/v1/service_grpc.pb.go b/pkg/kernel/proto/v1/service_grpc.pb.go new file mode 100644 index 0000000..7f3e044 --- /dev/null +++ b/pkg/kernel/proto/v1/service_grpc.pb.go @@ -0,0 +1,774 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc (unknown) +// source: pluggableharness/kernel/v1/service.proto + +// Package pluggableharness.kernel.v1 defines the kernel-callback service described +// in specifications/kernel-callbacks.md (RunSession, CountTokens, Emit, +// Log, ExportSpans, RecordMetrics, GetTelemetryConfig, GetConfig, Publish, +// Subscribe, ReadEvents, GetSession) — the plugin-to-kernel calling +// direction every plugin category gets at handshake, the reverse of every +// other category's protocol in this series. Unlike a category plugin +// protocol, this service carries no GetCapabilities/Configure RPCs: it +// isn't something the kernel dials into a plugin, it's the connection +// every plugin subprocess is handed back to call into the kernel. + +package kernelv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + KernelCallbackService_RunSession_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/RunSession" + KernelCallbackService_CountTokens_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/CountTokens" + KernelCallbackService_Emit_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/Emit" + KernelCallbackService_Log_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/Log" + KernelCallbackService_ExportSpans_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/ExportSpans" + KernelCallbackService_RecordMetrics_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/RecordMetrics" + KernelCallbackService_GetTelemetryConfig_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/GetTelemetryConfig" + KernelCallbackService_GetConfig_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/GetConfig" + KernelCallbackService_Publish_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/Publish" + KernelCallbackService_Subscribe_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/Subscribe" + KernelCallbackService_ReadEvents_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/ReadEvents" + KernelCallbackService_GetSession_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/GetSession" +) + +// KernelCallbackServiceClient is the client API for KernelCallbackService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// KernelCallbackService is the plugin-to-kernel callback channel described +// in specifications/kernel-callbacks.md §1. hashicorp/go-plugin natively +// supports bidirectional plugins, and this is that mechanism: every plugin +// subprocess, for every category defined across this series, MUST be given +// a client connection to this service at handshake time, unconditionally. +// A plugin that never calls back simply never uses it, but the channel's +// presence is not gated on category — a context provider needing +// CountTokens is just as valid a caller as a tool provider needing +// RunSession. +type KernelCallbackServiceClient interface { + // RunSession dispatches a nested sub-agent session under a named + // agent.hcl profile. Full semantics — profile resolution, budget + // inheritance, visibility of intermediate turns — are defined in + // agent-loop.md §7 and are not repeated here; kernel-callbacks.md §1 + // gives this RPC's calling contract. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "RunSessionResult", the exact name agent-loop.md §7.1 + // uses in its own data-type definition. Not a uniqueness violation: + // RunSessionResult is used by exactly this one RPC. + RunSession(ctx context.Context, in *RunSessionRequest, opts ...grpc.CallOption) (*RunSessionResult, error) + // CountTokens resolves the token-counting gap independently flagged by + // context.md §12, configuration.md §12, memory.md §13, and frontend.md + // §10: exactly one kernel-owned implementation, so that `tokens` figures + // produced by different providers stay mutually comparable and additive + // for configuration.md §6's budget-sum arithmetic. See + // kernel-callbacks.md §2 for the resolution algorithm and §3 for the + // single documented fallback formula. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "CountTokensResult", the exact name kernel-callbacks.md + // §2 uses. Not a uniqueness violation: used by exactly this one RPC. + CountTokens(ctx context.Context, in *CountTokensRequest, opts ...grpc.CallOption) (*CountTokensResult, error) + // Emit is how a plugin persists anything into the session's state + // backend. The kernel is the state backend's sole writer + // (state-backend.md §3) — a plugin never opens or writes the sqlite file + // directly; it calls Emit and the kernel performs the actual write, + // assigning the ordering-authoritative sequence number and the stable + // event id itself. See kernel-callbacks.md §4. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "EmitResult", the exact name kernel-callbacks.md §4 + // uses. Not a uniqueness violation: used by exactly this one RPC. + Emit(ctx context.Context, in *EmitRequest, opts ...grpc.CallOption) (*EmitResult, error) + // Log carries a plugin's own log output into the kernel's centralized + // logging, so it doesn't vanish into an unread subprocess stderr. + // Unlike Emit, a Log call is not tied to an active session — a plugin + // MAY call Log before any session exists (process startup, or from + // within Configure) or after one has ended (during shutdown). See + // kernel-callbacks.md §5. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "LogResult", the exact name kernel-callbacks.md §5 + // uses. Not a uniqueness violation: used by exactly this one RPC. + Log(ctx context.Context, in *LogRequest, opts ...grpc.CallOption) (*LogResult, error) + // ExportSpans relays a batch of a plugin's own completed trace spans to + // the kernel, which forwards them to the operator's configured + // collector essentially unchanged. This reverses an earlier + // direct-per-process-OTLP-export design — see + // specifications/observability.md#the-relay-model for why. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "ExportSpansResult", used by exactly this one RPC. + ExportSpans(ctx context.Context, in *ExportSpansRequest, opts ...grpc.CallOption) (*ExportSpansResult, error) + // RecordMetrics relays a batch of metric observations. Unlike + // ExportSpans, this is not a transparent relay: the kernel records each + // observation against its own instrument and bounds the attribute key + // set before it reaches any exporter. See + // specifications/observability.md#the-tracing-metrics-asymmetry. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "RecordMetricsResult", used by exactly this one RPC. + RecordMetrics(ctx context.Context, in *RecordMetricsRequest, opts ...grpc.CallOption) (*RecordMetricsResult, error) + // GetTelemetryConfig answers whether tracing/metrics/logs are enabled + // and at what level/ratio, so a plugin doesn't have to guess from its + // own environment. A plugin SHOULD call this once at startup and cache + // the result — see specifications/observability.md#gettelemetryconfig-caching. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "GetTelemetryConfigResult", used by exactly this one + // RPC. + GetTelemetryConfig(ctx context.Context, in *GetTelemetryConfigRequest, opts ...grpc.CallOption) (*GetTelemetryConfigResult, error) + // GetConfig returns the calling plugin's own already-decoded agent.hcl + // configuration — the same shape Configure received. See + // kernel-callbacks.md's GetConfig for the secret-echo MUST NOT rule this + // implies. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "GetConfigResult", used by exactly this one RPC. + GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResult, error) + // Publish emits one event onto the ephemeral, best-effort, cross-plugin + // event bus, distinct from Emit's durable per-session log and from + // hook dispatch's synchronous, agent.hcl-declared subscriber chain. See + // specifications/event-bus.md. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "PublishResult", used by exactly this one RPC. + Publish(ctx context.Context, in *PublishRequest, opts ...grpc.CallOption) (*PublishResult, error) + // Subscribe opens a server-streaming subscription to the event bus, + // filtered by topic. See specifications/event-bus.md#filter-grammar and + // #backpressure for why the kernel may unilaterally close this stream. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Stream element type is "BusEvent", naming the streamed domain concept + // rather than the RPC, the same convention model.md §4's StreamEvent + // and widget.md's WidgetUpdate already use. + Subscribe(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[BusEvent], error) + // ReadEvents reads back the calling plugin's own session's persisted + // event log, ordered by sequence — never by wall-clock time + // (.claude/rules/determinism.md). + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Stream element type is "StoredEvent", the same "name the domain + // concept" convention as Subscribe's BusEvent above. + ReadEvents(ctx context.Context, in *ReadEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StoredEvent], error) + // GetSession returns the calling plugin's own session's metadata plus + // its live, in-memory budget rollups. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "GetSessionResult", used by exactly this one RPC. + GetSession(ctx context.Context, in *GetSessionRequest, opts ...grpc.CallOption) (*GetSessionResult, error) +} + +type kernelCallbackServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewKernelCallbackServiceClient(cc grpc.ClientConnInterface) KernelCallbackServiceClient { + return &kernelCallbackServiceClient{cc} +} + +func (c *kernelCallbackServiceClient) RunSession(ctx context.Context, in *RunSessionRequest, opts ...grpc.CallOption) (*RunSessionResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RunSessionResult) + err := c.cc.Invoke(ctx, KernelCallbackService_RunSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) CountTokens(ctx context.Context, in *CountTokensRequest, opts ...grpc.CallOption) (*CountTokensResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CountTokensResult) + err := c.cc.Invoke(ctx, KernelCallbackService_CountTokens_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) Emit(ctx context.Context, in *EmitRequest, opts ...grpc.CallOption) (*EmitResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EmitResult) + err := c.cc.Invoke(ctx, KernelCallbackService_Emit_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) Log(ctx context.Context, in *LogRequest, opts ...grpc.CallOption) (*LogResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(LogResult) + err := c.cc.Invoke(ctx, KernelCallbackService_Log_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) ExportSpans(ctx context.Context, in *ExportSpansRequest, opts ...grpc.CallOption) (*ExportSpansResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ExportSpansResult) + err := c.cc.Invoke(ctx, KernelCallbackService_ExportSpans_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) RecordMetrics(ctx context.Context, in *RecordMetricsRequest, opts ...grpc.CallOption) (*RecordMetricsResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RecordMetricsResult) + err := c.cc.Invoke(ctx, KernelCallbackService_RecordMetrics_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) GetTelemetryConfig(ctx context.Context, in *GetTelemetryConfigRequest, opts ...grpc.CallOption) (*GetTelemetryConfigResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetTelemetryConfigResult) + err := c.cc.Invoke(ctx, KernelCallbackService_GetTelemetryConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetConfigResult) + err := c.cc.Invoke(ctx, KernelCallbackService_GetConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) Publish(ctx context.Context, in *PublishRequest, opts ...grpc.CallOption) (*PublishResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PublishResult) + err := c.cc.Invoke(ctx, KernelCallbackService_Publish_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) Subscribe(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[BusEvent], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &KernelCallbackService_ServiceDesc.Streams[0], KernelCallbackService_Subscribe_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[SubscribeRequest, BusEvent]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type KernelCallbackService_SubscribeClient = grpc.ServerStreamingClient[BusEvent] + +func (c *kernelCallbackServiceClient) ReadEvents(ctx context.Context, in *ReadEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StoredEvent], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &KernelCallbackService_ServiceDesc.Streams[1], KernelCallbackService_ReadEvents_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ReadEventsRequest, StoredEvent]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type KernelCallbackService_ReadEventsClient = grpc.ServerStreamingClient[StoredEvent] + +func (c *kernelCallbackServiceClient) GetSession(ctx context.Context, in *GetSessionRequest, opts ...grpc.CallOption) (*GetSessionResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetSessionResult) + err := c.cc.Invoke(ctx, KernelCallbackService_GetSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// KernelCallbackServiceServer is the server API for KernelCallbackService service. +// All implementations must embed UnimplementedKernelCallbackServiceServer +// for forward compatibility. +// +// KernelCallbackService is the plugin-to-kernel callback channel described +// in specifications/kernel-callbacks.md §1. hashicorp/go-plugin natively +// supports bidirectional plugins, and this is that mechanism: every plugin +// subprocess, for every category defined across this series, MUST be given +// a client connection to this service at handshake time, unconditionally. +// A plugin that never calls back simply never uses it, but the channel's +// presence is not gated on category — a context provider needing +// CountTokens is just as valid a caller as a tool provider needing +// RunSession. +type KernelCallbackServiceServer interface { + // RunSession dispatches a nested sub-agent session under a named + // agent.hcl profile. Full semantics — profile resolution, budget + // inheritance, visibility of intermediate turns — are defined in + // agent-loop.md §7 and are not repeated here; kernel-callbacks.md §1 + // gives this RPC's calling contract. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "RunSessionResult", the exact name agent-loop.md §7.1 + // uses in its own data-type definition. Not a uniqueness violation: + // RunSessionResult is used by exactly this one RPC. + RunSession(context.Context, *RunSessionRequest) (*RunSessionResult, error) + // CountTokens resolves the token-counting gap independently flagged by + // context.md §12, configuration.md §12, memory.md §13, and frontend.md + // §10: exactly one kernel-owned implementation, so that `tokens` figures + // produced by different providers stay mutually comparable and additive + // for configuration.md §6's budget-sum arithmetic. See + // kernel-callbacks.md §2 for the resolution algorithm and §3 for the + // single documented fallback formula. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "CountTokensResult", the exact name kernel-callbacks.md + // §2 uses. Not a uniqueness violation: used by exactly this one RPC. + CountTokens(context.Context, *CountTokensRequest) (*CountTokensResult, error) + // Emit is how a plugin persists anything into the session's state + // backend. The kernel is the state backend's sole writer + // (state-backend.md §3) — a plugin never opens or writes the sqlite file + // directly; it calls Emit and the kernel performs the actual write, + // assigning the ordering-authoritative sequence number and the stable + // event id itself. See kernel-callbacks.md §4. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "EmitResult", the exact name kernel-callbacks.md §4 + // uses. Not a uniqueness violation: used by exactly this one RPC. + Emit(context.Context, *EmitRequest) (*EmitResult, error) + // Log carries a plugin's own log output into the kernel's centralized + // logging, so it doesn't vanish into an unread subprocess stderr. + // Unlike Emit, a Log call is not tied to an active session — a plugin + // MAY call Log before any session exists (process startup, or from + // within Configure) or after one has ended (during shutdown). See + // kernel-callbacks.md §5. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "LogResult", the exact name kernel-callbacks.md §5 + // uses. Not a uniqueness violation: used by exactly this one RPC. + Log(context.Context, *LogRequest) (*LogResult, error) + // ExportSpans relays a batch of a plugin's own completed trace spans to + // the kernel, which forwards them to the operator's configured + // collector essentially unchanged. This reverses an earlier + // direct-per-process-OTLP-export design — see + // specifications/observability.md#the-relay-model for why. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "ExportSpansResult", used by exactly this one RPC. + ExportSpans(context.Context, *ExportSpansRequest) (*ExportSpansResult, error) + // RecordMetrics relays a batch of metric observations. Unlike + // ExportSpans, this is not a transparent relay: the kernel records each + // observation against its own instrument and bounds the attribute key + // set before it reaches any exporter. See + // specifications/observability.md#the-tracing-metrics-asymmetry. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "RecordMetricsResult", used by exactly this one RPC. + RecordMetrics(context.Context, *RecordMetricsRequest) (*RecordMetricsResult, error) + // GetTelemetryConfig answers whether tracing/metrics/logs are enabled + // and at what level/ratio, so a plugin doesn't have to guess from its + // own environment. A plugin SHOULD call this once at startup and cache + // the result — see specifications/observability.md#gettelemetryconfig-caching. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "GetTelemetryConfigResult", used by exactly this one + // RPC. + GetTelemetryConfig(context.Context, *GetTelemetryConfigRequest) (*GetTelemetryConfigResult, error) + // GetConfig returns the calling plugin's own already-decoded agent.hcl + // configuration — the same shape Configure received. See + // kernel-callbacks.md's GetConfig for the secret-echo MUST NOT rule this + // implies. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "GetConfigResult", used by exactly this one RPC. + GetConfig(context.Context, *GetConfigRequest) (*GetConfigResult, error) + // Publish emits one event onto the ephemeral, best-effort, cross-plugin + // event bus, distinct from Emit's durable per-session log and from + // hook dispatch's synchronous, agent.hcl-declared subscriber chain. See + // specifications/event-bus.md. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "PublishResult", used by exactly this one RPC. + Publish(context.Context, *PublishRequest) (*PublishResult, error) + // Subscribe opens a server-streaming subscription to the event bus, + // filtered by topic. See specifications/event-bus.md#filter-grammar and + // #backpressure for why the kernel may unilaterally close this stream. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Stream element type is "BusEvent", naming the streamed domain concept + // rather than the RPC, the same convention model.md §4's StreamEvent + // and widget.md's WidgetUpdate already use. + Subscribe(*SubscribeRequest, grpc.ServerStreamingServer[BusEvent]) error + // ReadEvents reads back the calling plugin's own session's persisted + // event log, ordered by sequence — never by wall-clock time + // (.claude/rules/determinism.md). + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Stream element type is "StoredEvent", the same "name the domain + // concept" convention as Subscribe's BusEvent above. + ReadEvents(*ReadEventsRequest, grpc.ServerStreamingServer[StoredEvent]) error + // GetSession returns the calling plugin's own session's metadata plus + // its live, in-memory budget rollups. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + // Response type is "GetSessionResult", used by exactly this one RPC. + GetSession(context.Context, *GetSessionRequest) (*GetSessionResult, error) + mustEmbedUnimplementedKernelCallbackServiceServer() +} + +// UnimplementedKernelCallbackServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedKernelCallbackServiceServer struct{} + +func (UnimplementedKernelCallbackServiceServer) RunSession(context.Context, *RunSessionRequest) (*RunSessionResult, error) { + return nil, status.Error(codes.Unimplemented, "method RunSession not implemented") +} +func (UnimplementedKernelCallbackServiceServer) CountTokens(context.Context, *CountTokensRequest) (*CountTokensResult, error) { + return nil, status.Error(codes.Unimplemented, "method CountTokens not implemented") +} +func (UnimplementedKernelCallbackServiceServer) Emit(context.Context, *EmitRequest) (*EmitResult, error) { + return nil, status.Error(codes.Unimplemented, "method Emit not implemented") +} +func (UnimplementedKernelCallbackServiceServer) Log(context.Context, *LogRequest) (*LogResult, error) { + return nil, status.Error(codes.Unimplemented, "method Log not implemented") +} +func (UnimplementedKernelCallbackServiceServer) ExportSpans(context.Context, *ExportSpansRequest) (*ExportSpansResult, error) { + return nil, status.Error(codes.Unimplemented, "method ExportSpans not implemented") +} +func (UnimplementedKernelCallbackServiceServer) RecordMetrics(context.Context, *RecordMetricsRequest) (*RecordMetricsResult, error) { + return nil, status.Error(codes.Unimplemented, "method RecordMetrics not implemented") +} +func (UnimplementedKernelCallbackServiceServer) GetTelemetryConfig(context.Context, *GetTelemetryConfigRequest) (*GetTelemetryConfigResult, error) { + return nil, status.Error(codes.Unimplemented, "method GetTelemetryConfig not implemented") +} +func (UnimplementedKernelCallbackServiceServer) GetConfig(context.Context, *GetConfigRequest) (*GetConfigResult, error) { + return nil, status.Error(codes.Unimplemented, "method GetConfig not implemented") +} +func (UnimplementedKernelCallbackServiceServer) Publish(context.Context, *PublishRequest) (*PublishResult, error) { + return nil, status.Error(codes.Unimplemented, "method Publish not implemented") +} +func (UnimplementedKernelCallbackServiceServer) Subscribe(*SubscribeRequest, grpc.ServerStreamingServer[BusEvent]) error { + return status.Error(codes.Unimplemented, "method Subscribe not implemented") +} +func (UnimplementedKernelCallbackServiceServer) ReadEvents(*ReadEventsRequest, grpc.ServerStreamingServer[StoredEvent]) error { + return status.Error(codes.Unimplemented, "method ReadEvents not implemented") +} +func (UnimplementedKernelCallbackServiceServer) GetSession(context.Context, *GetSessionRequest) (*GetSessionResult, error) { + return nil, status.Error(codes.Unimplemented, "method GetSession not implemented") +} +func (UnimplementedKernelCallbackServiceServer) mustEmbedUnimplementedKernelCallbackServiceServer() {} +func (UnimplementedKernelCallbackServiceServer) testEmbeddedByValue() {} + +// UnsafeKernelCallbackServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to KernelCallbackServiceServer will +// result in compilation errors. +type UnsafeKernelCallbackServiceServer interface { + mustEmbedUnimplementedKernelCallbackServiceServer() +} + +func RegisterKernelCallbackServiceServer(s grpc.ServiceRegistrar, srv KernelCallbackServiceServer) { + // If the following call panics, it indicates UnimplementedKernelCallbackServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&KernelCallbackService_ServiceDesc, srv) +} + +func _KernelCallbackService_RunSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RunSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).RunSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_RunSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).RunSession(ctx, req.(*RunSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_CountTokens_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CountTokensRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).CountTokens(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_CountTokens_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).CountTokens(ctx, req.(*CountTokensRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_Emit_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EmitRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).Emit(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_Emit_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).Emit(ctx, req.(*EmitRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_Log_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LogRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).Log(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_Log_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).Log(ctx, req.(*LogRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_ExportSpans_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ExportSpansRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).ExportSpans(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_ExportSpans_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).ExportSpans(ctx, req.(*ExportSpansRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_RecordMetrics_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RecordMetricsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).RecordMetrics(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_RecordMetrics_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).RecordMetrics(ctx, req.(*RecordMetricsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_GetTelemetryConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetTelemetryConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).GetTelemetryConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_GetTelemetryConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).GetTelemetryConfig(ctx, req.(*GetTelemetryConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_GetConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).GetConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_GetConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).GetConfig(ctx, req.(*GetConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_Publish_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PublishRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).Publish(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_Publish_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).Publish(ctx, req.(*PublishRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_Subscribe_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(SubscribeRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(KernelCallbackServiceServer).Subscribe(m, &grpc.GenericServerStream[SubscribeRequest, BusEvent]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type KernelCallbackService_SubscribeServer = grpc.ServerStreamingServer[BusEvent] + +func _KernelCallbackService_ReadEvents_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(ReadEventsRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(KernelCallbackServiceServer).ReadEvents(m, &grpc.GenericServerStream[ReadEventsRequest, StoredEvent]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type KernelCallbackService_ReadEventsServer = grpc.ServerStreamingServer[StoredEvent] + +func _KernelCallbackService_GetSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).GetSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_GetSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).GetSession(ctx, req.(*GetSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// KernelCallbackService_ServiceDesc is the grpc.ServiceDesc for KernelCallbackService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var KernelCallbackService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "pluggableharness.kernel.v1.KernelCallbackService", + HandlerType: (*KernelCallbackServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "RunSession", + Handler: _KernelCallbackService_RunSession_Handler, + }, + { + MethodName: "CountTokens", + Handler: _KernelCallbackService_CountTokens_Handler, + }, + { + MethodName: "Emit", + Handler: _KernelCallbackService_Emit_Handler, + }, + { + MethodName: "Log", + Handler: _KernelCallbackService_Log_Handler, + }, + { + MethodName: "ExportSpans", + Handler: _KernelCallbackService_ExportSpans_Handler, + }, + { + MethodName: "RecordMetrics", + Handler: _KernelCallbackService_RecordMetrics_Handler, + }, + { + MethodName: "GetTelemetryConfig", + Handler: _KernelCallbackService_GetTelemetryConfig_Handler, + }, + { + MethodName: "GetConfig", + Handler: _KernelCallbackService_GetConfig_Handler, + }, + { + MethodName: "Publish", + Handler: _KernelCallbackService_Publish_Handler, + }, + { + MethodName: "GetSession", + Handler: _KernelCallbackService_GetSession_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "Subscribe", + Handler: _KernelCallbackService_Subscribe_Handler, + ServerStreams: true, + }, + { + StreamName: "ReadEvents", + Handler: _KernelCallbackService_ReadEvents_Handler, + ServerStreams: true, + }, + }, + Metadata: "pluggableharness/kernel/v1/service.proto", +} diff --git a/pkg/kernel/proto/v1/types.pb.go b/pkg/kernel/proto/v1/types.pb.go new file mode 100644 index 0000000..7709985 --- /dev/null +++ b/pkg/kernel/proto/v1/types.pb.go @@ -0,0 +1,189 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/kernel/v1/types.proto + +package kernelv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// EventKind identifies the shape of an EmitRequest's opaque payload. This +// is state-backend.md §5's authoritative kind enum, restated here only +// because it is the wire-level type Emit actually carries — +// state-backend.md §5 remains authoritative if the two ever need +// reconciling again. Usage/cost figures, Render() output, and +// session_start/session_end deliberately do NOT get their own EventKind; +// see state-backend.md §5 for why. +type EventKind int32 + +const ( + // Zero value. Never valid on the wire; its presence means a caller + // forgot to set the field. + EventKind_EVENT_KIND_UNSPECIFIED EventKind = 0 + // A completed model turn's canonical message (model.md §5). Usage + // and cost figures live inside this payload, not as separate fields or + // a separate EventKind (state-backend.md §5). + EventKind_EVENT_KIND_MESSAGE EventKind = 1 + // A tool invocation's call. + EventKind_EVENT_KIND_TOOL_CALL EventKind = 2 + // A tool invocation's result. + EventKind_EVENT_KIND_TOOL_RESULT EventKind = 3 + // A built Plan, prior to plan-ready dispatch. + EventKind_EVENT_KIND_PLAN EventKind = 4 + // The outcome of applying a Plan. + EventKind_EVENT_KIND_APPLY EventKind = 5 + // A context provider's Contribute output, or a memory provider's + // Recall output after kernel translation (memory.md §6). + EventKind_EVENT_KIND_CONTEXT_CONTRIBUTION EventKind = 6 + // A memory provider's write of a new record. + EventKind_EVENT_KIND_MEMORY_WRITE EventKind = 7 + // A memory provider's update of an existing record. + EventKind_EVENT_KIND_MEMORY_UPDATE EventKind = 8 + // A memory provider's deletion of an existing record. + EventKind_EVENT_KIND_MEMORY_DELETE EventKind = 9 + // Kernel-synthesized when a transform or veto hook subscriber fails + // (agent-loop/hook-dispatch.md#subscriber-error-handling) — never + // emitted by a plugin's own Emit call, only by the kernel itself + // dispatching a hook. Payload shape is + // pluggableharness.hook.v1.HookError, wrapped by the forthcoming + // event.v1 package's HookErrorEvent (state-backend.md §5). + EventKind_EVENT_KIND_HOOK_ERROR EventKind = 10 +) + +// Enum value maps for EventKind. +var ( + EventKind_name = map[int32]string{ + 0: "EVENT_KIND_UNSPECIFIED", + 1: "EVENT_KIND_MESSAGE", + 2: "EVENT_KIND_TOOL_CALL", + 3: "EVENT_KIND_TOOL_RESULT", + 4: "EVENT_KIND_PLAN", + 5: "EVENT_KIND_APPLY", + 6: "EVENT_KIND_CONTEXT_CONTRIBUTION", + 7: "EVENT_KIND_MEMORY_WRITE", + 8: "EVENT_KIND_MEMORY_UPDATE", + 9: "EVENT_KIND_MEMORY_DELETE", + 10: "EVENT_KIND_HOOK_ERROR", + } + EventKind_value = map[string]int32{ + "EVENT_KIND_UNSPECIFIED": 0, + "EVENT_KIND_MESSAGE": 1, + "EVENT_KIND_TOOL_CALL": 2, + "EVENT_KIND_TOOL_RESULT": 3, + "EVENT_KIND_PLAN": 4, + "EVENT_KIND_APPLY": 5, + "EVENT_KIND_CONTEXT_CONTRIBUTION": 6, + "EVENT_KIND_MEMORY_WRITE": 7, + "EVENT_KIND_MEMORY_UPDATE": 8, + "EVENT_KIND_MEMORY_DELETE": 9, + "EVENT_KIND_HOOK_ERROR": 10, + } +) + +func (x EventKind) Enum() *EventKind { + p := new(EventKind) + *p = x + return p +} + +func (x EventKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (EventKind) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_kernel_v1_types_proto_enumTypes[0].Descriptor() +} + +func (EventKind) Type() protoreflect.EnumType { + return &file_pluggableharness_kernel_v1_types_proto_enumTypes[0] +} + +func (x EventKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use EventKind.Descriptor instead. +func (EventKind) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_types_proto_rawDescGZIP(), []int{0} +} + +var File_pluggableharness_kernel_v1_types_proto protoreflect.FileDescriptor + +const file_pluggableharness_kernel_v1_types_proto_rawDesc = "" + + "\n" + + "&pluggableharness/kernel/v1/types.proto\x12\x1apluggableharness.kernel.v1*\xb9\x02\n" + + "\tEventKind\x12\x1a\n" + + "\x16EVENT_KIND_UNSPECIFIED\x10\x00\x12\x16\n" + + "\x12EVENT_KIND_MESSAGE\x10\x01\x12\x18\n" + + "\x14EVENT_KIND_TOOL_CALL\x10\x02\x12\x1a\n" + + "\x16EVENT_KIND_TOOL_RESULT\x10\x03\x12\x13\n" + + "\x0fEVENT_KIND_PLAN\x10\x04\x12\x14\n" + + "\x10EVENT_KIND_APPLY\x10\x05\x12#\n" + + "\x1fEVENT_KIND_CONTEXT_CONTRIBUTION\x10\x06\x12\x1b\n" + + "\x17EVENT_KIND_MEMORY_WRITE\x10\a\x12\x1c\n" + + "\x18EVENT_KIND_MEMORY_UPDATE\x10\b\x12\x1c\n" + + "\x18EVENT_KIND_MEMORY_DELETE\x10\t\x12\x19\n" + + "\x15EVENT_KIND_HOOK_ERROR\x10\n" + + "B@Z>github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1b\x06proto3" + +var ( + file_pluggableharness_kernel_v1_types_proto_rawDescOnce sync.Once + file_pluggableharness_kernel_v1_types_proto_rawDescData []byte +) + +func file_pluggableharness_kernel_v1_types_proto_rawDescGZIP() []byte { + file_pluggableharness_kernel_v1_types_proto_rawDescOnce.Do(func() { + file_pluggableharness_kernel_v1_types_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_kernel_v1_types_proto_rawDesc), len(file_pluggableharness_kernel_v1_types_proto_rawDesc))) + }) + return file_pluggableharness_kernel_v1_types_proto_rawDescData +} + +var file_pluggableharness_kernel_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_pluggableharness_kernel_v1_types_proto_goTypes = []any{ + (EventKind)(0), // 0: pluggableharness.kernel.v1.EventKind +} +var file_pluggableharness_kernel_v1_types_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_pluggableharness_kernel_v1_types_proto_init() } +func file_pluggableharness_kernel_v1_types_proto_init() { + if File_pluggableharness_kernel_v1_types_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_kernel_v1_types_proto_rawDesc), len(file_pluggableharness_kernel_v1_types_proto_rawDesc)), + NumEnums: 1, + NumMessages: 0, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_kernel_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_kernel_v1_types_proto_depIdxs, + EnumInfos: file_pluggableharness_kernel_v1_types_proto_enumTypes, + }.Build() + File_pluggableharness_kernel_v1_types_proto = out.File + file_pluggableharness_kernel_v1_types_proto_goTypes = nil + file_pluggableharness_kernel_v1_types_proto_depIdxs = nil +} diff --git a/pkg/kernel/session.go b/pkg/kernel/session.go new file mode 100644 index 0000000..34e8cc1 --- /dev/null +++ b/pkg/kernel/session.go @@ -0,0 +1,60 @@ +package kernel + +import ( + "context" + "fmt" + + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" +) + +// RunSession dispatches a nested sub-agent session under a named agent.hcl +// profile, blocking until the child session reaches a terminal status. Full +// turn-by-turn semantics — profile resolution, budget inheritance, +// visibility of intermediate turns to the parent — live in +// agent-loop/subagents.md; kernel-callbacks.md#the-callback-channel gives +// only this RPC's wire-level calling contract. +// +// RunSession is session-scoped via req.ParentSessionId rather than a field +// literally named session_id: it both identifies the calling (parent) +// session and creates a new child, so kernel-callbacks.md names the field +// for the relationship it establishes. req.RemainingDepth and +// req.RemainingCostBudgetUsd MUST be set to the caller's own inherited, +// only-shrinking budgets — the child is never able to widen either, only +// spend down what it was given. req is passed through directly: between +// Profile, Prompt, ParentSessionId, RemainingDepth, and +// RemainingCostBudgetUsd, this request has more required fields than an +// options-struct-of-parameters could hold without becoming its own +// re-implementation of the generated type. +// +// The result's TotalCostUsd/TotalInputTokens/TotalOutputTokens are a +// whole-session rollup summed across every turn the child ran (including +// its own descendant sub-agent sessions) — a different shape from one +// completion call's per-call Usage — letting a caller do budget-aware +// fan-out without separately re-summing the child's event history itself. +func (c *Client) RunSession(ctx context.Context, req *kernelv1.RunSessionRequest) (*kernelv1.RunSessionResult, error) { + result, err := c.raw.RunSession(ctx, req) + if err != nil { + return nil, fmt.Errorf("kernel: run session: %w", err) + } + return result, nil +} + +// GetSession returns the calling plugin's own session's metadata plus its +// live, in-memory budget rollups — the same state-backend.md-backed +// SessionInfo the frontend protocol already uses, extended with two fields +// state backend deliberately never persists (kernel-callbacks.md#getsession). +// +// sessionID is mandatory and MUST name the session this plugin was +// actually invoked for, the same one-session-only rule Emit documents. +// The result's Info.CostUsd is the persisted cost_ledger SUM — read back, +// never re-walked and re-summed here — while RemainingDepth and +// RemainingCostBudgetUsd are live, in-memory figures recomputed at spawn +// time and spent down at each RunSession hop; a caller cannot derive +// either of those two from Info alone. +func (c *Client) GetSession(ctx context.Context, sessionID string) (*kernelv1.GetSessionResult, error) { + result, err := c.raw.GetSession(ctx, &kernelv1.GetSessionRequest{SessionId: sessionID}) + if err != nil { + return nil, fmt.Errorf("kernel: get session: %w", err) + } + return result, nil +} diff --git a/pkg/kernel/session_test.go b/pkg/kernel/session_test.go new file mode 100644 index 0000000..446393f --- /dev/null +++ b/pkg/kernel/session_test.go @@ -0,0 +1,101 @@ +package kernel_test + +import ( + "errors" + "testing" + + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" +) + +func TestClient_RunSession(t *testing.T) { + t.Parallel() + + var gotReq *kernelv1.RunSessionRequest + srv := &fakeServer{ + runSessionFunc: func(req *kernelv1.RunSessionRequest) (*kernelv1.RunSessionResult, error) { + gotReq = req + return &kernelv1.RunSessionResult{ + SessionId: "child-01", + TotalCostUsd: 0.5, + }, nil + }, + } + c := newTestClient(t, srv) + + req := &kernelv1.RunSessionRequest{ + Profile: "reviewer", + Prompt: "review this diff", + ParentSessionId: "parent-01", + RemainingDepth: 3, + RemainingCostBudgetUsd: 10, + } + result, err := c.RunSession(t.Context(), req) + if err != nil { + t.Fatalf("RunSession: %v", err) + } + if result.GetSessionId() != "child-01" || result.GetTotalCostUsd() != 0.5 { + t.Errorf("RunSession() = %+v, want session_id=child-01 total_cost_usd=0.5", result) + } + if gotReq.GetProfile() != "reviewer" || gotReq.GetParentSessionId() != "parent-01" { + t.Errorf("server received %+v, want profile=reviewer parent_session_id=parent-01", gotReq) + } +} + +func TestClient_RunSession_error(t *testing.T) { + t.Parallel() + + wantErr := errors.New("boom") + srv := &fakeServer{ + runSessionFunc: func(*kernelv1.RunSessionRequest) (*kernelv1.RunSessionResult, error) { + return nil, wantErr + }, + } + c := newTestClient(t, srv) + + if _, err := c.RunSession(t.Context(), &kernelv1.RunSessionRequest{}); err == nil { + t.Fatal("RunSession: want error, got nil") + } +} + +func TestClient_GetSession(t *testing.T) { + t.Parallel() + + var gotReq *kernelv1.GetSessionRequest + srv := &fakeServer{ + getSessionFunc: func(req *kernelv1.GetSessionRequest) (*kernelv1.GetSessionResult, error) { + gotReq = req + return &kernelv1.GetSessionResult{ + RemainingDepth: 2, + RemainingCostBudgetUsd: 3.25, + }, nil + }, + } + c := newTestClient(t, srv) + + result, err := c.GetSession(t.Context(), "session-01") + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if result.GetRemainingDepth() != 2 || result.GetRemainingCostBudgetUsd() != 3.25 { + t.Errorf("GetSession() = %+v, want remaining_depth=2 remaining_cost_budget_usd=3.25", result) + } + if gotReq.GetSessionId() != "session-01" { + t.Errorf("server received %+v, want session_id=session-01", gotReq) + } +} + +func TestClient_GetSession_error(t *testing.T) { + t.Parallel() + + wantErr := errors.New("boom") + srv := &fakeServer{ + getSessionFunc: func(*kernelv1.GetSessionRequest) (*kernelv1.GetSessionResult, error) { + return nil, wantErr + }, + } + c := newTestClient(t, srv) + + if _, err := c.GetSession(t.Context(), "session-01"); err == nil { + t.Fatal("GetSession: want error, got nil") + } +} diff --git a/pkg/kernel/slog.go b/pkg/kernel/slog.go new file mode 100644 index 0000000..16ff639 --- /dev/null +++ b/pkg/kernel/slog.go @@ -0,0 +1,263 @@ +package kernel + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + logv1 "github.com/pluggableharness/agent/pkg/log/proto/v1" + + "google.golang.org/protobuf/types/known/timestamppb" +) + +// defaultFlushInterval and defaultMaxBatchSize are NewSlogHandler's +// fallback batching parameters when not overridden via +// WithFlushInterval/WithMaxBatchSize — chosen so a plugin logging at a +// normal rate sees its entries reach the kernel within a second, while a +// plugin logging in a tight loop (e.g. at TRACE) doesn't pay one RPC per +// line. +const ( + defaultFlushInterval = time.Second + defaultMaxBatchSize = 100 +) + +// slogSink is the shared, mutex-guarded state a SlogHandler and every +// handler WithAttrs/WithGroup derives from it all flush through — split +// out from SlogHandler itself because WithAttrs/WithGroup return a new +// SlogHandler value (per slog's own documented handler-derivation +// pattern), and a sync.Mutex must never be copied +// (go vet's copylocks check) — see SlogHandler's own doc comment. +type slogSink struct { + client *Client + sessionID *string + flushInterval time.Duration + maxBatch int + + mu sync.Mutex + pending []*logv1.LogEntry + + closeOnce sync.Once + done chan struct{} + wg sync.WaitGroup +} + +// SlogHandlerOption configures NewSlogHandler. +type SlogHandlerOption func(*slogSink) + +// WithSessionID attaches session_id to every batch this handler flushes +// (kernel-callbacks.md#log: MAY be omitted, set when log output is +// attributable to a specific session). Omit this option for +// startup/shutdown/Configure-time logging that predates or outlives any +// session. +func WithSessionID(sessionID string) SlogHandlerOption { + return func(s *slogSink) { s.sessionID = &sessionID } +} + +// WithFlushInterval overrides how often a SlogHandler flushes its pending +// batch on a timer, regardless of size. Omitting this option leaves +// defaultFlushInterval. +func WithFlushInterval(d time.Duration) SlogHandlerOption { + return func(s *slogSink) { + if d > 0 { + s.flushInterval = d + } + } +} + +// WithMaxBatchSize overrides how many pending entries trigger an +// immediate flush from Handle, ahead of the next timer tick. Omitting +// this option leaves defaultMaxBatchSize. +func WithMaxBatchSize(n int) SlogHandlerOption { + return func(s *slogSink) { + if n > 0 { + s.maxBatch = n + } + } +} + +// SlogHandler is a log/slog.Handler that batches records and flushes them +// via Log (kernel-callbacks.md#log) — the logging half of this package's +// plugin-author-facing surface (see the package doc comment). Construct +// one with (*Client).NewSlogHandler; the zero value is not usable. +// +// SlogHandler carries no lock of its own — sink is a pointer shared by +// every handler value WithAttrs/WithGroup derives, so deriving a new +// handler (slog's own With/WithGroup mechanism) is a cheap value copy, +// never a lock copy. +type SlogHandler struct { + sink *slogSink + level *slog.Level // nil: fall back to sink.client.LogLevel() via wireToLevel + groupPrefix string + attrs []slog.Attr +} + +// NewSlogHandler returns a SlogHandler flushing batches through c. A +// background goroutine flushes on flushInterval (defaultFlushInterval +// unless overridden); Handle also flushes immediately once maxBatch +// entries are pending (defaultMaxBatchSize unless overridden). Call Close +// before the plugin process exits so any still-pending entries aren't +// lost. +func (c *Client) NewSlogHandler(opts ...SlogHandlerOption) *SlogHandler { + sink := &slogSink{ + client: c, + flushInterval: defaultFlushInterval, + maxBatch: defaultMaxBatchSize, + done: make(chan struct{}), + } + for _, opt := range opts { + opt(sink) + } + sink.wg.Add(1) + go sink.flushLoop() + return &SlogHandler{sink: sink} +} + +// Enabled reports whether level is at or above this handler's own level +// (WithLevel), or, absent that, the kernel-reported floor +// (Client.LogLevel, via LoadTelemetryConfig) translated to an slog.Level. +// A Client that never called LoadTelemetryConfig reports LOG_LEVEL_INFO +// (Client.LogLevel's own documented default), so Enabled is never +// unconditionally true before a plugin bootstraps its telemetry config. +func (h *SlogHandler) Enabled(_ context.Context, level slog.Level) bool { + if h.level != nil { + return level >= *h.level + } + return level >= wireToLevel(h.sink.client.LogLevel()) +} + +// WithLevel overrides the level Enabled compares against, ahead of the +// kernel-reported floor. Returns a new *SlogHandler; the receiver is +// unchanged. +func (h *SlogHandler) WithLevel(level slog.Level) *SlogHandler { + h2 := *h + h2.level = &level + return &h2 +} + +// WithAttrs implements slog.Handler: returns a new handler whose Handle +// calls include attrs on every record, in addition to that call's own +// attrs. Keys are prefixed with the handler's current group, matching +// slog's own documented WithGroup/WithAttrs interaction. +func (h *SlogHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + if len(attrs) == 0 { + return h + } + merged := make([]slog.Attr, len(h.attrs), len(h.attrs)+len(attrs)) + copy(merged, h.attrs) + for _, a := range attrs { + if h.groupPrefix != "" { + a = slog.Attr{Key: h.groupPrefix + "." + a.Key, Value: a.Value} + } + merged = append(merged, a) + } + h2 := *h + h2.attrs = merged + return &h2 +} + +// WithGroup implements slog.Handler: returns a new handler whose +// subsequent WithAttrs-accumulated (and per-call Handle) attribute keys +// are prefixed with name, dot-joined with any existing group prefix. +func (h *SlogHandler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + h2 := *h + if h2.groupPrefix == "" { + h2.groupPrefix = name + } else { + h2.groupPrefix = h2.groupPrefix + "." + name + } + return &h2 +} + +// Handle implements slog.Handler: converts r into a wire LogEntry +// (prefixing r's own attrs with this handler's group, same as WithAttrs +// above) and queues it, flushing immediately if the pending batch has +// reached sink.maxBatch. +func (h *SlogHandler) Handle(ctx context.Context, r slog.Record) error { + fieldAttrs := make([]slog.Attr, 0, len(h.attrs)+r.NumAttrs()) + fieldAttrs = append(fieldAttrs, h.attrs...) + r.Attrs(func(a slog.Attr) bool { + if h.groupPrefix != "" { + a = slog.Attr{Key: h.groupPrefix + "." + a.Key, Value: a.Value} + } + fieldAttrs = append(fieldAttrs, a) + return true + }) + + fields, err := structFromAttrs(fieldAttrs) + if err != nil { + return fmt.Errorf("kernel: handle: %w", err) + } + + entry := &logv1.LogEntry{ + Level: levelToWire(r.Level), + Message: r.Message, + Fields: fields, + Time: timestamppb.New(r.Time), + } + + h.sink.mu.Lock() + h.sink.pending = append(h.sink.pending, entry) + full := len(h.sink.pending) >= h.sink.maxBatch + h.sink.mu.Unlock() + + if full { + return h.Flush(ctx) + } + return nil +} + +// Flush sends every currently-pending entry via one Log call, regardless +// of the timer or maxBatch threshold. A no-op returning nil if nothing is +// pending. +func (h *SlogHandler) Flush(ctx context.Context) error { + return h.sink.flush(ctx) +} + +// Close stops the background flush timer and flushes any remaining +// pending entries. Idempotent — safe to call more than once. Call this +// before the plugin process exits. +func (h *SlogHandler) Close() error { + h.sink.closeOnce.Do(func() { + close(h.sink.done) + }) + h.sink.wg.Wait() + return h.sink.flush(context.Background()) +} + +func (s *slogSink) flush(ctx context.Context) error { + s.mu.Lock() + if len(s.pending) == 0 { + s.mu.Unlock() + return nil + } + batch := s.pending + s.pending = nil + s.mu.Unlock() + + if _, err := s.client.raw.Log(ctx, &kernelv1.LogRequest{SessionId: s.sessionID, Entries: batch}); err != nil { + return fmt.Errorf("kernel: flush: %w", err) + } + return nil +} + +func (s *slogSink) flushLoop() { + defer s.wg.Done() + ticker := time.NewTicker(s.flushInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + _ = s.flush(context.Background()) + case <-s.done: + return + } + } +} + +var _ slog.Handler = (*SlogHandler)(nil) diff --git a/pkg/kernel/slog_test.go b/pkg/kernel/slog_test.go new file mode 100644 index 0000000..804008d --- /dev/null +++ b/pkg/kernel/slog_test.go @@ -0,0 +1,236 @@ +package kernel_test + +import ( + "context" + "log/slog" + "sync" + "testing" + "time" + + "github.com/pluggableharness/agent/pkg/kernel" + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + logv1 "github.com/pluggableharness/agent/pkg/log/proto/v1" +) + +// logCapture records every LogRequest a fakeServer's Log method receives. +type logCapture struct { + mu sync.Mutex + batch []*kernelv1.LogRequest +} + +func (c *logCapture) record(req *kernelv1.LogRequest) (*kernelv1.LogResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.batch = append(c.batch, req) + return &kernelv1.LogResult{}, nil +} + +func (c *logCapture) requests() []*kernelv1.LogRequest { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]*kernelv1.LogRequest, len(c.batch)) + copy(out, c.batch) + return out +} + +func (c *logCapture) totalEntries() int { + c.mu.Lock() + defer c.mu.Unlock() + n := 0 + for _, req := range c.batch { + n += len(req.GetEntries()) + } + return n +} + +func TestSlogHandler_flushesOnMaxBatchSize(t *testing.T) { + t.Parallel() + + capture := &logCapture{} + c := newTestClient(t, &fakeServer{logFunc: capture.record}) + + h := c.NewSlogHandler(kernel.WithMaxBatchSize(2), kernel.WithFlushInterval(time.Hour)) + t.Cleanup(func() { _ = h.Close() }) + logger := slog.New(h) + + logger.Info("first") + logger.Info("second") // crosses maxBatch=2, should flush immediately + + deadline := time.Now().Add(2 * time.Second) + for capture.totalEntries() < 2 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if got := capture.totalEntries(); got != 2 { + t.Fatalf("totalEntries() = %d, want 2", got) + } +} + +func TestSlogHandler_flushesOnTimer(t *testing.T) { + t.Parallel() + + capture := &logCapture{} + c := newTestClient(t, &fakeServer{logFunc: capture.record}) + + h := c.NewSlogHandler(kernel.WithFlushInterval(20 * time.Millisecond)) + t.Cleanup(func() { _ = h.Close() }) + logger := slog.New(h) + + logger.Info("one entry, well under any batch-size threshold") + + deadline := time.Now().Add(2 * time.Second) + for capture.totalEntries() < 1 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if got := capture.totalEntries(); got != 1 { + t.Fatalf("totalEntries() = %d, want 1 (timer-triggered flush)", got) + } +} + +func TestSlogHandler_closeFlushesRemaining(t *testing.T) { + t.Parallel() + + capture := &logCapture{} + c := newTestClient(t, &fakeServer{logFunc: capture.record}) + + h := c.NewSlogHandler(kernel.WithFlushInterval(time.Hour), kernel.WithMaxBatchSize(1000)) + logger := slog.New(h) + + logger.Info("pending at close") + if err := h.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if got := capture.totalEntries(); got != 1 { + t.Fatalf("totalEntries() after Close = %d, want 1", got) + } +} + +func TestSlogHandler_sessionID(t *testing.T) { + t.Parallel() + + capture := &logCapture{} + c := newTestClient(t, &fakeServer{logFunc: capture.record}) + + h := c.NewSlogHandler(kernel.WithSessionID("sess-123"), kernel.WithFlushInterval(time.Hour), kernel.WithMaxBatchSize(1000)) + logger := slog.New(h) + logger.Info("hi") + if err := h.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + reqs := capture.requests() + if len(reqs) != 1 || reqs[0].GetSessionId() != "sess-123" { + t.Fatalf("requests = %+v, want one request with session_id=sess-123", reqs) + } +} + +func TestSlogHandler_enabledUsesKernelReportedLevel(t *testing.T) { + t.Parallel() + + c := newTestClient(t, &fakeServer{ + getTelemetryConfigFunc: func(*kernelv1.GetTelemetryConfigRequest) (*kernelv1.GetTelemetryConfigResult, error) { + return &kernelv1.GetTelemetryConfigResult{LogLevel: logv1.LogLevel_LOG_LEVEL_WARN}, nil + }, + }) + if err := c.LoadTelemetryConfig(t.Context()); err != nil { + t.Fatalf("LoadTelemetryConfig: %v", err) + } + + h := c.NewSlogHandler() + t.Cleanup(func() { _ = h.Close() }) + + if h.Enabled(context.Background(), slog.LevelInfo) { + t.Error("Enabled(Info) = true, want false (kernel-reported floor is WARN)") + } + if !h.Enabled(context.Background(), slog.LevelWarn) { + t.Error("Enabled(Warn) = false, want true") + } +} + +func TestSlogHandler_withLevelOverridesKernelFloor(t *testing.T) { + t.Parallel() + + c := newTestClient(t, &fakeServer{ + getTelemetryConfigFunc: func(*kernelv1.GetTelemetryConfigRequest) (*kernelv1.GetTelemetryConfigResult, error) { + return &kernelv1.GetTelemetryConfigResult{LogLevel: logv1.LogLevel_LOG_LEVEL_ERROR}, nil + }, + }) + if err := c.LoadTelemetryConfig(t.Context()); err != nil { + t.Fatalf("LoadTelemetryConfig: %v", err) + } + + h := c.NewSlogHandler().WithLevel(slog.LevelDebug) + t.Cleanup(func() { _ = h.Close() }) + + if !h.Enabled(context.Background(), slog.LevelDebug) { + t.Error("Enabled(Debug) = false, want true (WithLevel overrides the kernel-reported ERROR floor)") + } +} + +func TestSlogHandler_groupPrefixesPerCallAttrs(t *testing.T) { + t.Parallel() + + capture := &logCapture{} + c := newTestClient(t, &fakeServer{logFunc: capture.record}) + + h := c.NewSlogHandler(kernel.WithFlushInterval(time.Hour), kernel.WithMaxBatchSize(1000)) + logger := slog.New(h).WithGroup("req") + logger.Info("handled", "method", "GET") // "method" arrives via Record.Attrs, not WithAttrs + if err := h.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + fields := capture.requests()[0].GetEntries()[0].GetFields().GetFields() + if fields["req.method"].GetStringValue() != "GET" { + t.Errorf("req.method = %v, want GET (Handle's own r.Attrs() loop must prefix with the active group)", fields["req.method"]) + } +} + +func TestSlogHandler_withGroupEmptyNameIsNoop(t *testing.T) { + t.Parallel() + + c := newTestClient(t, &fakeServer{}) + h := c.NewSlogHandler() + t.Cleanup(func() { _ = h.Close() }) + + if h.WithGroup("") != h { + t.Error("WithGroup(\"\") should return the same handler unchanged") + } +} + +func TestSlogHandler_withAttrsEmptyIsNoop(t *testing.T) { + t.Parallel() + + c := newTestClient(t, &fakeServer{}) + h := c.NewSlogHandler() + t.Cleanup(func() { _ = h.Close() }) + + if h.WithAttrs(nil) != h { + t.Error("WithAttrs(nil) should return the same handler unchanged") + } +} + +func TestSlogHandler_withAttrsAndGroup(t *testing.T) { + t.Parallel() + + capture := &logCapture{} + c := newTestClient(t, &fakeServer{logFunc: capture.record}) + + h := c.NewSlogHandler(kernel.WithFlushInterval(time.Hour), kernel.WithMaxBatchSize(1000)) + logger := slog.New(h).With("base", "b").WithGroup("req").With("method", "GET") + logger.Info("handled") + if err := h.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + reqs := capture.requests() + if len(reqs) != 1 || len(reqs[0].GetEntries()) != 1 { + t.Fatalf("requests = %+v, want one request with one entry", reqs) + } + fields := reqs[0].GetEntries()[0].GetFields().GetFields() + if fields["base"].GetStringValue() != "b" { + t.Errorf("base = %v, want b (no group prefix — set before WithGroup)", fields["base"]) + } + if fields["req.method"].GetStringValue() != "GET" { + t.Errorf("req.method = %v, want GET (prefixed by the active group)", fields["req.method"]) + } +} diff --git a/pkg/kernel/span.go b/pkg/kernel/span.go new file mode 100644 index 0000000..05e2a6b --- /dev/null +++ b/pkg/kernel/span.go @@ -0,0 +1,263 @@ +package kernel + +import ( + "context" + "fmt" + + "go.opentelemetry.io/otel/attribute" + otelcodes "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + oteltrace "go.opentelemetry.io/otel/trace" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + tracev1 "github.com/pluggableharness/agent/pkg/trace/proto/v1" +) + +// SpanExporter is an sdktrace.SpanExporter that relays completed spans to +// the kernel via ExportSpans (kernel-callbacks.md#exportspans, +// observability.md#the-relay-model) — the tracing half of this package's +// plugin-author-facing surface. A plugin author wires this into an +// ordinary sdktrace.TracerProvider (sdktrace.WithBatcher(exporter)) and +// writes normal tracer.Start(...) code; the relay transport is invisible. +// +// SpanExporter deliberately does NOT create or modify spans itself — it +// only translates already-completed ReadOnlySpan values into the wire +// trace.v1.Span shape, preserving every identity/timing field exactly +// (observability.md#span-relay-is-transparent's MUST NOT-alter rule +// applies just as much on this side of the relay as it does kernel-side). +type SpanExporter struct { + client *Client + sessionID *string +} + +// SpanExporterOption configures NewSpanExporter. +type SpanExporterOption func(*SpanExporter) + +// WithExportSessionID attaches session_id to every ExportSpans call this +// exporter makes (kernel-callbacks.md#exportspans: MAY be omitted). Omit +// this option for spans produced outside any session context (Configure, +// startup). +func WithExportSessionID(sessionID string) SpanExporterOption { + return func(e *SpanExporter) { e.sessionID = &sessionID } +} + +// NewSpanExporter returns a SpanExporter relaying through c. +func (c *Client) NewSpanExporter(opts ...SpanExporterOption) *SpanExporter { + e := &SpanExporter{client: c} + for _, opt := range opts { + opt(e) + } + return e +} + +// ExportSpans implements sdktrace.SpanExporter: translates spans into +// wire trace.v1.Span messages and relays them via one ExportSpans call. +// An empty spans slice is a no-op, matching ExportSpansRequest's own +// MUST-be-non-empty rule (nothing to send, not an error). +func (e *SpanExporter) ExportSpans(ctx context.Context, spans []sdktrace.ReadOnlySpan) error { + if len(spans) == 0 { + return nil + } + wireSpans := make([]*tracev1.Span, 0, len(spans)) + for _, s := range spans { + ws, err := convertReadOnlySpan(s) + if err != nil { + return fmt.Errorf("kernel: export spans: %w", err) + } + wireSpans = append(wireSpans, ws) + } + + _, err := e.client.raw.ExportSpans(ctx, &kernelv1.ExportSpansRequest{ + SessionId: e.sessionID, + Spans: wireSpans, + }) + if err != nil { + return fmt.Errorf("kernel: export spans: %w", err) + } + return nil +} + +// Shutdown implements sdktrace.SpanExporter. There is no per-exporter +// resource to release — the underlying Client's connection lifetime is +// managed by whoever constructed it — so this is a no-op returning nil. +func (e *SpanExporter) Shutdown(context.Context) error { + return nil +} + +var _ sdktrace.SpanExporter = (*SpanExporter)(nil) + +// convertReadOnlySpan translates one sdktrace.ReadOnlySpan into its wire +// trace.v1.Span equivalent, preserving every identity/timing field +// verbatim. +func convertReadOnlySpan(s sdktrace.ReadOnlySpan) (*tracev1.Span, error) { + sc := s.SpanContext() + traceID := sc.TraceID().String() + spanID := sc.SpanID().String() + + var parentSpanID *string + if parent := s.Parent(); parent.IsValid() { + id := parent.SpanID().String() + parentSpanID = &id + } + + attrs, err := structFromKeyValues(s.Attributes()) + if err != nil { + return nil, fmt.Errorf("span attributes: %w", err) + } + + events, err := convertEvents(s.Events()) + if err != nil { + return nil, err + } + links, err := convertLinks(s.Links()) + if err != nil { + return nil, err + } + + scope := s.InstrumentationScope() + + return &tracev1.Span{ + TraceId: traceID, + SpanId: spanID, + ParentSpanId: parentSpanID, + Name: s.Name(), + Kind: convertSpanKind(s.SpanKind()), + StartTime: timestamppb.New(s.StartTime()), + EndTime: timestamppb.New(s.EndTime()), + Status: convertStatus(s.Status()), + Attributes: attrs, + Events: events, + Links: links, + Scope: &tracev1.InstrumentationScope{Name: scope.Name, Version: scope.Version}, + }, nil +} + +var spanKindToWire = map[oteltrace.SpanKind]tracev1.SpanKind{ + oteltrace.SpanKindUnspecified: tracev1.SpanKind_SPAN_KIND_UNSPECIFIED, + oteltrace.SpanKindInternal: tracev1.SpanKind_SPAN_KIND_INTERNAL, + oteltrace.SpanKindServer: tracev1.SpanKind_SPAN_KIND_SERVER, + oteltrace.SpanKindClient: tracev1.SpanKind_SPAN_KIND_CLIENT, + oteltrace.SpanKindProducer: tracev1.SpanKind_SPAN_KIND_PRODUCER, + oteltrace.SpanKindConsumer: tracev1.SpanKind_SPAN_KIND_CONSUMER, +} + +func convertSpanKind(kind oteltrace.SpanKind) tracev1.SpanKind { + if wire, ok := spanKindToWire[kind]; ok { + return wire + } + return tracev1.SpanKind_SPAN_KIND_UNSPECIFIED +} + +func convertStatus(status sdktrace.Status) *tracev1.Status { + code := tracev1.StatusCode_STATUS_CODE_UNSPECIFIED + switch status.Code { + case otelcodes.Ok: + code = tracev1.StatusCode_STATUS_CODE_OK + case otelcodes.Error: + code = tracev1.StatusCode_STATUS_CODE_ERROR + case otelcodes.Unset: + code = tracev1.StatusCode_STATUS_CODE_UNSPECIFIED + } + return &tracev1.Status{Code: code, Message: status.Description} +} + +func convertEvents(events []sdktrace.Event) ([]*tracev1.SpanEvent, error) { + if len(events) == 0 { + return nil, nil + } + out := make([]*tracev1.SpanEvent, 0, len(events)) + for _, e := range events { + attrs, err := structFromKeyValues(e.Attributes) + if err != nil { + return nil, fmt.Errorf("event %q attributes: %w", e.Name, err) + } + out = append(out, &tracev1.SpanEvent{ + Name: e.Name, + Time: timestamppb.New(e.Time), + Attributes: attrs, + }) + } + return out, nil +} + +func convertLinks(links []sdktrace.Link) ([]*tracev1.SpanLink, error) { + if len(links) == 0 { + return nil, nil + } + out := make([]*tracev1.SpanLink, 0, len(links)) + for _, l := range links { + attrs, err := structFromKeyValues(l.Attributes) + if err != nil { + return nil, fmt.Errorf("link attributes: %w", err) + } + out = append(out, &tracev1.SpanLink{ + TraceId: l.SpanContext.TraceID().String(), + SpanId: l.SpanContext.SpanID().String(), + Attributes: attrs, + }) + } + return out, nil +} + +// structFromKeyValues converts a list of OTel attribute.KeyValue pairs +// into a google.protobuf.Struct — the same Struct carve-out +// trace.v1.Span.attributes documents. Later duplicate keys win, matching +// attribute.NewSet's own last-value-wins convention for repeated keys. +func structFromKeyValues(attrs []attribute.KeyValue) (*structpb.Struct, error) { + if len(attrs) == 0 { + return nil, nil + } + fields := make(map[string]any, len(attrs)) + for _, kv := range attrs { + fields[string(kv.Key)] = attributeValueToAny(kv.Value) + } + // structpb.NewStruct is the one fallible step here (e.g. a STRING + // attribute whose value happens not to be valid UTF-8) — + // attributeValueToAny itself cannot fail, since every OTel attribute + // kind converts to a structpb.NewValue-compatible Go type. + s, err := structpb.NewStruct(fields) + if err != nil { + return nil, fmt.Errorf("attributes: %w", err) + } + return s, nil +} + +// attributeValueToAny converts one OTel attribute.Value into a +// structpb.NewValue-compatible Go value. Slice-typed values (BOOLSLICE, +// INT64SLICE, FLOAT64SLICE, STRINGSLICE) convert element-wise into []any, +// since structpb.NewValue itself only accepts []any, not a concretely-typed +// slice. +func attributeValueToAny(v attribute.Value) any { + switch v.Type() { + case attribute.BOOL: + return v.AsBool() + case attribute.INT64: + return v.AsInt64() + case attribute.FLOAT64: + return v.AsFloat64() + case attribute.STRING: + return v.AsString() + case attribute.BOOLSLICE: + return toAnySlice(v.AsBoolSlice()) + case attribute.INT64SLICE: + return toAnySlice(v.AsInt64Slice()) + case attribute.FLOAT64SLICE: + return toAnySlice(v.AsFloat64Slice()) + case attribute.STRINGSLICE: + return toAnySlice(v.AsStringSlice()) + default: + return v.String() // attribute.INVALID or any future kind: best-effort string form + } +} + +// toAnySlice converts a concretely-typed slice into []any, the shape +// structpb.NewValue requires for a ListValue. +func toAnySlice[T any](s []T) []any { + out := make([]any, len(s)) + for i, v := range s { + out[i] = v + } + return out +} diff --git a/pkg/kernel/span_test.go b/pkg/kernel/span_test.go new file mode 100644 index 0000000..6e7cf9f --- /dev/null +++ b/pkg/kernel/span_test.go @@ -0,0 +1,315 @@ +package kernel_test + +import ( + "context" + "sync" + "testing" + "time" + + "go.opentelemetry.io/otel/attribute" + otelcodes "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + oteltrace "go.opentelemetry.io/otel/trace" + + "github.com/pluggableharness/agent/pkg/kernel" + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + tracev1 "github.com/pluggableharness/agent/pkg/trace/proto/v1" +) + +// spanCapture records every ExportSpansRequest a fakeServer's ExportSpans +// method receives. +type spanCapture struct { + mu sync.Mutex + reqs []*kernelv1.ExportSpansRequest +} + +func (c *spanCapture) record(req *kernelv1.ExportSpansRequest) (*kernelv1.ExportSpansResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.reqs = append(c.reqs, req) + return &kernelv1.ExportSpansResult{}, nil +} + +func (c *spanCapture) requests() []*kernelv1.ExportSpansRequest { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]*kernelv1.ExportSpansRequest, len(c.reqs)) + copy(out, c.reqs) + return out +} + +// realSpan builds one genuine sdktrace.ReadOnlySpan by running it through +// a real in-memory TracerProvider — the most faithful way to exercise +// convertReadOnlySpan against actual SDK-produced identity/timing values, +// rather than a hand-built fake that might not match the SDK's real +// field shapes. +func realSpan(t *testing.T, name string, attrs ...attribute.KeyValue) sdktrace.ReadOnlySpan { + t.Helper() + + exp := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp, sdktrace.WithBatchTimeout(time.Millisecond))) + t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) + + tracer := tp.Tracer("test-scope", oteltrace.WithInstrumentationVersion("1.0.0")) + _, span := tracer.Start(context.Background(), name, oteltrace.WithAttributes(attrs...)) + span.End() + + if err := tp.ForceFlush(context.Background()); err != nil { + t.Fatalf("ForceFlush: %v", err) + } + spans := exp.GetSpans() + if len(spans) != 1 { + t.Fatalf("got %d spans, want 1", len(spans)) + } + return spans[0].Snapshot() +} + +func TestSpanExporter_exportSpans(t *testing.T) { + t.Parallel() + + capture := &spanCapture{} + c := newTestClient(t, &fakeServer{exportSpansFunc: capture.record}) + exporter := c.NewSpanExporter() + + span := realSpan(t, "tool.execute", attribute.String("tool.name", "github")) + + if err := exporter.ExportSpans(t.Context(), []sdktrace.ReadOnlySpan{span}); err != nil { + t.Fatalf("ExportSpans: %v", err) + } + + reqs := capture.requests() + if len(reqs) != 1 || len(reqs[0].GetSpans()) != 1 { + t.Fatalf("requests = %+v, want one request with one span", reqs) + } + + got := reqs[0].GetSpans()[0] + if got.GetName() != "tool.execute" { + t.Errorf("Name = %q, want tool.execute", got.GetName()) + } + if len(got.GetTraceId()) != 32 { + t.Errorf("TraceId = %q, want 32 lowercase hex chars", got.GetTraceId()) + } + if len(got.GetSpanId()) != 16 { + t.Errorf("SpanId = %q, want 16 lowercase hex chars", got.GetSpanId()) + } + if got.GetScope().GetName() != "test-scope" || got.GetScope().GetVersion() != "1.0.0" { + t.Errorf("Scope = %+v, want test-scope/1.0.0", got.GetScope()) + } + if got.GetAttributes().GetFields()["tool.name"].GetStringValue() != "github" { + t.Errorf("attributes[tool.name] = %v, want github", got.GetAttributes()) + } +} + +func TestSpanExporter_emptyIsNoop(t *testing.T) { + t.Parallel() + + capture := &spanCapture{} + c := newTestClient(t, &fakeServer{exportSpansFunc: capture.record}) + exporter := c.NewSpanExporter() + + if err := exporter.ExportSpans(t.Context(), nil); err != nil { + t.Fatalf("ExportSpans(nil): %v", err) + } + if got := capture.requests(); len(got) != 0 { + t.Fatalf("requests = %+v, want none", got) + } +} + +func TestSpanExporter_sessionID(t *testing.T) { + t.Parallel() + + capture := &spanCapture{} + c := newTestClient(t, &fakeServer{exportSpansFunc: capture.record}) + exporter := c.NewSpanExporter(kernel.WithExportSessionID("sess-1")) + + span := realSpan(t, "x") + if err := exporter.ExportSpans(t.Context(), []sdktrace.ReadOnlySpan{span}); err != nil { + t.Fatalf("ExportSpans: %v", err) + } + + reqs := capture.requests() + if len(reqs) != 1 || reqs[0].GetSessionId() != "sess-1" { + t.Fatalf("requests = %+v, want one request with session_id=sess-1", reqs) + } +} + +func TestSpanExporter_shutdownIsNoop(t *testing.T) { + t.Parallel() + + c := newTestClient(t, &fakeServer{}) + exporter := c.NewSpanExporter() + if err := exporter.Shutdown(t.Context()); err != nil { + t.Errorf("Shutdown: %v", err) + } +} + +func TestSpanExporter_rootSpanHasNilParent(t *testing.T) { + t.Parallel() + + capture := &spanCapture{} + c := newTestClient(t, &fakeServer{exportSpansFunc: capture.record}) + exporter := c.NewSpanExporter() + + span := realSpan(t, "root") + if err := exporter.ExportSpans(t.Context(), []sdktrace.ReadOnlySpan{span}); err != nil { + t.Fatalf("ExportSpans: %v", err) + } + got := capture.requests()[0].GetSpans()[0] + if got.ParentSpanId != nil { + t.Errorf("ParentSpanId = %v, want nil for a root span", got.ParentSpanId) + } +} + +func TestSpanExporter_eventsLinksAndSliceAttributes(t *testing.T) { + t.Parallel() + + exp := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp, sdktrace.WithBatchTimeout(time.Millisecond))) + t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) + tracer := tp.Tracer("test-scope") + + _, linked := tracer.Start(context.Background(), "linked") + linked.End() + + _, span := tracer.Start(context.Background(), "with-event-and-link", + oteltrace.WithLinks(oteltrace.LinkFromContext(oteltrace.ContextWithSpan(context.Background(), linked), + attribute.String("link.reason", "related"))), + oteltrace.WithAttributes( + attribute.StringSlice("tags", []string{"a", "b"}), + attribute.Int64Slice("counts", []int64{1, 2}), + attribute.Float64Slice("ratios", []float64{0.5, 1.5}), + attribute.BoolSlice("flags", []bool{true, false}), + ), + ) + span.AddEvent("checkpoint", oteltrace.WithAttributes(attribute.String("stage", "start"))) + span.End() + + if err := tp.ForceFlush(context.Background()); err != nil { + t.Fatalf("ForceFlush: %v", err) + } + var target sdktrace.ReadOnlySpan + for _, s := range exp.GetSpans() { + if s.Name == "with-event-and-link" { + target = s.Snapshot() + } + } + if target == nil { + t.Fatal("span not found") + } + + capture := &spanCapture{} + c := newTestClient(t, &fakeServer{exportSpansFunc: capture.record}) + exporter := c.NewSpanExporter() + if err := exporter.ExportSpans(t.Context(), []sdktrace.ReadOnlySpan{target}); err != nil { + t.Fatalf("ExportSpans: %v", err) + } + + got := capture.requests()[0].GetSpans()[0] + if len(got.GetEvents()) != 1 || got.GetEvents()[0].GetName() != "checkpoint" { + t.Errorf("Events = %+v, want one event named checkpoint", got.GetEvents()) + } + if got.GetEvents()[0].GetAttributes().GetFields()["stage"].GetStringValue() != "start" { + t.Errorf("event attributes = %v, want stage=start", got.GetEvents()[0].GetAttributes()) + } + if len(got.GetLinks()) != 1 || len(got.GetLinks()[0].GetSpanId()) != 16 { + t.Errorf("Links = %+v, want one link with a 16-char span id", got.GetLinks()) + } + if got.GetLinks()[0].GetAttributes().GetFields()["link.reason"].GetStringValue() != "related" { + t.Errorf("link attributes = %v, want link.reason=related", got.GetLinks()[0].GetAttributes()) + } + + attrs := got.GetAttributes().GetFields() + if tags := attrs["tags"].GetListValue().GetValues(); len(tags) != 2 || tags[0].GetStringValue() != "a" { + t.Errorf("tags = %v, want [a, b]", attrs["tags"]) + } + if counts := attrs["counts"].GetListValue().GetValues(); len(counts) != 2 || counts[0].GetNumberValue() != 1 { + t.Errorf("counts = %v, want [1, 2]", attrs["counts"]) + } + if ratios := attrs["ratios"].GetListValue().GetValues(); len(ratios) != 2 { + t.Errorf("ratios = %v, want 2 elements", attrs["ratios"]) + } + if flags := attrs["flags"].GetListValue().GetValues(); len(flags) != 2 || !flags[0].GetBoolValue() { + t.Errorf("flags = %v, want [true, false]", attrs["flags"]) + } +} + +func TestSpanExporter_errorStatus(t *testing.T) { + t.Parallel() + + exp := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp, sdktrace.WithBatchTimeout(time.Millisecond))) + t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) + tracer := tp.Tracer("test-scope") + + _, span := tracer.Start(context.Background(), "failing") + span.SetStatus(otelcodes.Error, "boom") + span.End() + + if err := tp.ForceFlush(context.Background()); err != nil { + t.Fatalf("ForceFlush: %v", err) + } + + capture := &spanCapture{} + c := newTestClient(t, &fakeServer{exportSpansFunc: capture.record}) + exporter := c.NewSpanExporter() + if err := exporter.ExportSpans(t.Context(), []sdktrace.ReadOnlySpan{exp.GetSpans()[0].Snapshot()}); err != nil { + t.Fatalf("ExportSpans: %v", err) + } + + got := capture.requests()[0].GetSpans()[0] + if got.GetStatus().GetCode() != tracev1.StatusCode_STATUS_CODE_ERROR { + t.Errorf("Status.Code = %v, want STATUS_CODE_ERROR", got.GetStatus().GetCode()) + } + if got.GetStatus().GetMessage() != "boom" { + t.Errorf("Status.Message = %q, want boom", got.GetStatus().GetMessage()) + } +} + +func TestSpanExporter_childSpanHasParent(t *testing.T) { + t.Parallel() + + exp := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp, sdktrace.WithBatchTimeout(time.Millisecond))) + t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) + tracer := tp.Tracer("test-scope") + + ctx, parent := tracer.Start(context.Background(), "parent") + _, child := tracer.Start(ctx, "child") + child.End() + parent.End() + + if err := tp.ForceFlush(context.Background()); err != nil { + t.Fatalf("ForceFlush: %v", err) + } + spans := exp.GetSpans() + if len(spans) != 2 { + t.Fatalf("got %d spans, want 2", len(spans)) + } + + capture := &spanCapture{} + c := newTestClient(t, &fakeServer{exportSpansFunc: capture.record}) + exporter := c.NewSpanExporter() + + var readOnly []sdktrace.ReadOnlySpan + for _, s := range spans { + readOnly = append(readOnly, s.Snapshot()) + } + if err := exporter.ExportSpans(t.Context(), readOnly); err != nil { + t.Fatalf("ExportSpans: %v", err) + } + + got := capture.requests()[0].GetSpans() + var childWire *tracev1.Span + for _, s := range got { + if s.GetName() == "child" { + childWire = s + } + } + if childWire == nil { + t.Fatal("child span not found in relayed batch") + } + if childWire.ParentSpanId == nil || len(childWire.GetParentSpanId()) != 16 { + t.Errorf("child.ParentSpanId = %v, want a 16-char hex parent id", childWire.ParentSpanId) + } +} diff --git a/pkg/kernel/toanyslice_test.go b/pkg/kernel/toanyslice_test.go new file mode 100644 index 0000000..91ad547 --- /dev/null +++ b/pkg/kernel/toanyslice_test.go @@ -0,0 +1,25 @@ +package kernel + +import "testing" + +func TestToAnySlice(t *testing.T) { + t.Parallel() + + got := toAnySlice([]int64{1, 2, 3}) + if len(got) != 3 { + t.Fatalf("len(got) = %d, want 3", len(got)) + } + for i, want := range []int64{1, 2, 3} { + if got[i] != want { + t.Errorf("got[%d] = %v, want %v", i, got[i], want) + } + } +} + +func TestToAnySlice_empty(t *testing.T) { + t.Parallel() + got := toAnySlice([]string{}) + if len(got) != 0 { + t.Fatalf("len(got) = %d, want 0", len(got)) + } +} diff --git a/pkg/kernel/tokens.go b/pkg/kernel/tokens.go new file mode 100644 index 0000000..5ca8197 --- /dev/null +++ b/pkg/kernel/tokens.go @@ -0,0 +1,37 @@ +package kernel + +import ( + "context" + "fmt" + + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" +) + +// CountTokens resolves an exact-if-possible token count for req's content, +// optionally preferring req.ModelRef's tokenizer when that model provider +// implements its own optional CountTokens RPC (kernel-callbacks.md#counttokens). +// The result's Exact field distinguishes a real vendor tokenizer's count +// from the kernel's single documented fallback heuristic +// (kernel-callbacks.md#the-fallback-heuristic: +// ceil(total_utf8_byte_length/4)) — a caller MUST NOT re-derive that +// formula itself, this RPC is the one place it's implemented. +// +// CountTokens is plugin-scoped: req carries no session_id field, and this +// call is valid regardless of whether the calling plugin is currently +// invoked for any session (kernel-callbacks.md's plugin-scoped vs. +// session-scoped split). req is passed through directly rather than +// exploded into discrete parameters — Content (repeated, MUST be set) plus +// the optional ModelRef already form the request's full, minimal shape, so +// an options-struct-of-parameters would just be reproducing the generated +// type's own two fields. +// +// context.md and memory.md providers MUST route their own `tokens` field +// computation through this call rather than an arbitrary provider-local +// heuristic — see kernel-callbacks.md#why-a-kernel-primitive-not-a-provider-local-heuristic. +func (c *Client) CountTokens(ctx context.Context, req *kernelv1.CountTokensRequest) (*kernelv1.CountTokensResult, error) { + result, err := c.raw.CountTokens(ctx, req) + if err != nil { + return nil, fmt.Errorf("kernel: count tokens: %w", err) + } + return result, nil +} diff --git a/pkg/kernel/tokens_test.go b/pkg/kernel/tokens_test.go new file mode 100644 index 0000000..a2d8306 --- /dev/null +++ b/pkg/kernel/tokens_test.go @@ -0,0 +1,52 @@ +package kernel_test + +import ( + "errors" + "testing" + + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" +) + +func TestClient_CountTokens(t *testing.T) { + t.Parallel() + + var gotReq *kernelv1.CountTokensRequest + srv := &fakeServer{ + countTokensFunc: func(req *kernelv1.CountTokensRequest) (*kernelv1.CountTokensResult, error) { + gotReq = req + return &kernelv1.CountTokensResult{Count: 42, Exact: true}, nil + }, + } + c := newTestClient(t, srv) + + req := &kernelv1.CountTokensRequest{ + Content: []*contentv1.ContentBlock{{}}, + } + result, err := c.CountTokens(t.Context(), req) + if err != nil { + t.Fatalf("CountTokens: %v", err) + } + if result.GetCount() != 42 || !result.GetExact() { + t.Errorf("CountTokens() = %+v, want count=42 exact=true", result) + } + if len(gotReq.GetContent()) != 1 { + t.Errorf("server received %+v, want one content block", gotReq) + } +} + +func TestClient_CountTokens_error(t *testing.T) { + t.Parallel() + + wantErr := errors.New("boom") + srv := &fakeServer{ + countTokensFunc: func(*kernelv1.CountTokensRequest) (*kernelv1.CountTokensResult, error) { + return nil, wantErr + }, + } + c := newTestClient(t, srv) + + if _, err := c.CountTokens(t.Context(), &kernelv1.CountTokensRequest{}); err == nil { + t.Fatal("CountTokens: want error, got nil") + } +} diff --git a/pkg/log/proto/v1/log.pb.go b/pkg/log/proto/v1/types.pb.go similarity index 76% rename from pkg/log/proto/v1/log.pb.go rename to pkg/log/proto/v1/types.pb.go index 0b18c85..e2e5dc5 100644 --- a/pkg/log/proto/v1/log.pb.go +++ b/pkg/log/proto/v1/types.pb.go @@ -2,7 +2,7 @@ // versions: // protoc-gen-go v1.36.11 // protoc (unknown) -// source: pluggableharness/log/v1/log.proto +// source: pluggableharness/log/v1/types.proto // Package pluggableharness.log.v1 defines the structured log entry types described // in specifications/kernel-callbacks.md §5 — how a plugin's own log output @@ -85,11 +85,11 @@ func (x LogLevel) String() string { } func (LogLevel) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_log_v1_log_proto_enumTypes[0].Descriptor() + return file_pluggableharness_log_v1_types_proto_enumTypes[0].Descriptor() } func (LogLevel) Type() protoreflect.EnumType { - return &file_pluggableharness_log_v1_log_proto_enumTypes[0] + return &file_pluggableharness_log_v1_types_proto_enumTypes[0] } func (x LogLevel) Number() protoreflect.EnumNumber { @@ -98,7 +98,7 @@ func (x LogLevel) Number() protoreflect.EnumNumber { // Deprecated: Use LogLevel.Descriptor instead. func (LogLevel) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_log_v1_log_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_log_v1_types_proto_rawDescGZIP(), []int{0} } // LogEntry is one structured log line, mirroring Go's log/slog record @@ -128,7 +128,7 @@ type LogEntry struct { func (x *LogEntry) Reset() { *x = LogEntry{} - mi := &file_pluggableharness_log_v1_log_proto_msgTypes[0] + mi := &file_pluggableharness_log_v1_types_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -140,7 +140,7 @@ func (x *LogEntry) String() string { func (*LogEntry) ProtoMessage() {} func (x *LogEntry) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_log_v1_log_proto_msgTypes[0] + mi := &file_pluggableharness_log_v1_types_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -153,7 +153,7 @@ func (x *LogEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use LogEntry.ProtoReflect.Descriptor instead. func (*LogEntry) Descriptor() ([]byte, []int) { - return file_pluggableharness_log_v1_log_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_log_v1_types_proto_rawDescGZIP(), []int{0} } func (x *LogEntry) GetLevel() LogLevel { @@ -191,11 +191,11 @@ func (x *LogEntry) GetTime() *timestamppb.Timestamp { return nil } -var File_pluggableharness_log_v1_log_proto protoreflect.FileDescriptor +var File_pluggableharness_log_v1_types_proto protoreflect.FileDescriptor -const file_pluggableharness_log_v1_log_proto_rawDesc = "" + +const file_pluggableharness_log_v1_types_proto_rawDesc = "" + "\n" + - "!pluggableharness/log/v1/log.proto\x12\x17pluggableharness.log.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xd6\x01\n" + + "#pluggableharness/log/v1/types.proto\x12\x17pluggableharness.log.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xd6\x01\n" + "\bLogEntry\x127\n" + "\x05level\x18\x01 \x01(\x0e2!.pluggableharness.log.v1.LogLevelR\x05level\x12\x16\n" + "\x06logger\x18\x02 \x01(\tR\x06logger\x12\x18\n" + @@ -212,26 +212,26 @@ const file_pluggableharness_log_v1_log_proto_rawDesc = "" + "\x0fLOG_LEVEL_FATAL\x10\x06B:Z8github.com/pluggableharness/agent/pkg/log/proto/v1;logv1b\x06proto3" var ( - file_pluggableharness_log_v1_log_proto_rawDescOnce sync.Once - file_pluggableharness_log_v1_log_proto_rawDescData []byte + file_pluggableharness_log_v1_types_proto_rawDescOnce sync.Once + file_pluggableharness_log_v1_types_proto_rawDescData []byte ) -func file_pluggableharness_log_v1_log_proto_rawDescGZIP() []byte { - file_pluggableharness_log_v1_log_proto_rawDescOnce.Do(func() { - file_pluggableharness_log_v1_log_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_log_v1_log_proto_rawDesc), len(file_pluggableharness_log_v1_log_proto_rawDesc))) +func file_pluggableharness_log_v1_types_proto_rawDescGZIP() []byte { + file_pluggableharness_log_v1_types_proto_rawDescOnce.Do(func() { + file_pluggableharness_log_v1_types_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_log_v1_types_proto_rawDesc), len(file_pluggableharness_log_v1_types_proto_rawDesc))) }) - return file_pluggableharness_log_v1_log_proto_rawDescData + return file_pluggableharness_log_v1_types_proto_rawDescData } -var file_pluggableharness_log_v1_log_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_pluggableharness_log_v1_log_proto_msgTypes = make([]protoimpl.MessageInfo, 1) -var file_pluggableharness_log_v1_log_proto_goTypes = []any{ +var file_pluggableharness_log_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_pluggableharness_log_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_pluggableharness_log_v1_types_proto_goTypes = []any{ (LogLevel)(0), // 0: pluggableharness.log.v1.LogLevel (*LogEntry)(nil), // 1: pluggableharness.log.v1.LogEntry (*structpb.Struct)(nil), // 2: google.protobuf.Struct (*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp } -var file_pluggableharness_log_v1_log_proto_depIdxs = []int32{ +var file_pluggableharness_log_v1_types_proto_depIdxs = []int32{ 0, // 0: pluggableharness.log.v1.LogEntry.level:type_name -> pluggableharness.log.v1.LogLevel 2, // 1: pluggableharness.log.v1.LogEntry.fields:type_name -> google.protobuf.Struct 3, // 2: pluggableharness.log.v1.LogEntry.time:type_name -> google.protobuf.Timestamp @@ -242,27 +242,27 @@ var file_pluggableharness_log_v1_log_proto_depIdxs = []int32{ 0, // [0:3] is the sub-list for field type_name } -func init() { file_pluggableharness_log_v1_log_proto_init() } -func file_pluggableharness_log_v1_log_proto_init() { - if File_pluggableharness_log_v1_log_proto != nil { +func init() { file_pluggableharness_log_v1_types_proto_init() } +func file_pluggableharness_log_v1_types_proto_init() { + if File_pluggableharness_log_v1_types_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_log_v1_log_proto_rawDesc), len(file_pluggableharness_log_v1_log_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_log_v1_types_proto_rawDesc), len(file_pluggableharness_log_v1_types_proto_rawDesc)), NumEnums: 1, NumMessages: 1, NumExtensions: 0, NumServices: 0, }, - GoTypes: file_pluggableharness_log_v1_log_proto_goTypes, - DependencyIndexes: file_pluggableharness_log_v1_log_proto_depIdxs, - EnumInfos: file_pluggableharness_log_v1_log_proto_enumTypes, - MessageInfos: file_pluggableharness_log_v1_log_proto_msgTypes, + GoTypes: file_pluggableharness_log_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_log_v1_types_proto_depIdxs, + EnumInfos: file_pluggableharness_log_v1_types_proto_enumTypes, + MessageInfos: file_pluggableharness_log_v1_types_proto_msgTypes, }.Build() - File_pluggableharness_log_v1_log_proto = out.File - file_pluggableharness_log_v1_log_proto_goTypes = nil - file_pluggableharness_log_v1_log_proto_depIdxs = nil + File_pluggableharness_log_v1_types_proto = out.File + file_pluggableharness_log_v1_types_proto_goTypes = nil + file_pluggableharness_log_v1_types_proto_depIdxs = nil } diff --git a/pkg/memory/capabilities.go b/pkg/memory/capabilities.go new file mode 100644 index 0000000..435d457 --- /dev/null +++ b/pkg/memory/capabilities.go @@ -0,0 +1,32 @@ +package memory + +import ( + memoryv1 "github.com/pluggableharness/agent/pkg/memory/proto/v1" +) + +// capabilitiesToProto converts caps into the wire +// memoryv1.MemoryCapabilities GetCapabilities returns +// (docs/specifications/memory/data-types.md#memorycapabilities). There is +// no reverse conversion: GetCapabilities only ever flows provider → kernel, +// never decoded back by this SDK. +func capabilitiesToProto(caps Capabilities) *memoryv1.MemoryCapabilities { + types := make([]memoryv1.MemoryType, 0, len(caps.SupportedTypes)) + for _, t := range caps.SupportedTypes { + types = append(types, toProtoMemoryType(t)) + } + + scopes := make([]memoryv1.MemoryScope, 0, len(caps.SupportedScopes)) + for _, s := range caps.SupportedScopes { + scopes = append(scopes, toProtoMemoryScope(s)) + } + + return &memoryv1.MemoryCapabilities{ + DefaultTokenBudget: caps.DefaultTokenBudget, + SupportedTypes: types, + SupportedScopes: scopes, + RatificationSupported: caps.RatificationSupported, + SlashCommands: caps.SlashCommands, + ConfigSchema: caps.ConfigSchema, + SupportedHookPoints: caps.SupportedHookPoints, + } +} diff --git a/pkg/memory/convert.go b/pkg/memory/convert.go new file mode 100644 index 0000000..93c6281 --- /dev/null +++ b/pkg/memory/convert.go @@ -0,0 +1,336 @@ +package memory + +import ( + "fmt" + "math" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/pluggableharness/agent/pkg/content" + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + memoryv1 "github.com/pluggableharness/agent/pkg/memory/proto/v1" +) + +// clampInt32 converts v to int32, clamping to [math.MinInt32, math.MaxInt32] +// rather than an unchecked narrowing conversion (gosec G115). A wire int64 +// token count is never expected to exceed that range in practice, but a +// clamp is cheap insurance against silently wrapping into a negative or +// nonsensical value if it ever did. +func clampInt32(v int64) int32 { + switch { + case v > math.MaxInt32: + return math.MaxInt32 + case v < math.MinInt32: + return math.MinInt32 + default: + return int32(v) + } +} + +// toProtoMemoryType converts a domain Type to its wire enum value. +func toProtoMemoryType(t Type) memoryv1.MemoryType { + switch t { + case TypeUser: + return memoryv1.MemoryType_MEMORY_TYPE_USER + case TypeFeedback: + return memoryv1.MemoryType_MEMORY_TYPE_FEEDBACK + case TypeProject: + return memoryv1.MemoryType_MEMORY_TYPE_PROJECT + case TypeReference: + return memoryv1.MemoryType_MEMORY_TYPE_REFERENCE + default: + return memoryv1.MemoryType_MEMORY_TYPE_UNSPECIFIED + } +} + +// fromProtoMemoryType converts a wire Type enum value to its domain +// equivalent. +func fromProtoMemoryType(t memoryv1.MemoryType) Type { + switch t { + case memoryv1.MemoryType_MEMORY_TYPE_USER: + return TypeUser + case memoryv1.MemoryType_MEMORY_TYPE_FEEDBACK: + return TypeFeedback + case memoryv1.MemoryType_MEMORY_TYPE_PROJECT: + return TypeProject + case memoryv1.MemoryType_MEMORY_TYPE_REFERENCE: + return TypeReference + default: + return TypeUnspecified + } +} + +// toProtoMemoryScope converts a domain Scope to its wire enum value. +func toProtoMemoryScope(s Scope) memoryv1.MemoryScope { + switch s { + case ScopeSession: + return memoryv1.MemoryScope_MEMORY_SCOPE_SESSION + case ScopeProject: + return memoryv1.MemoryScope_MEMORY_SCOPE_PROJECT + case ScopeGlobal: + return memoryv1.MemoryScope_MEMORY_SCOPE_GLOBAL + default: + return memoryv1.MemoryScope_MEMORY_SCOPE_UNSPECIFIED + } +} + +// fromProtoMemoryScope converts a wire Scope enum value to its domain +// equivalent. +func fromProtoMemoryScope(s memoryv1.MemoryScope) Scope { + switch s { + case memoryv1.MemoryScope_MEMORY_SCOPE_SESSION: + return ScopeSession + case memoryv1.MemoryScope_MEMORY_SCOPE_PROJECT: + return ScopeProject + case memoryv1.MemoryScope_MEMORY_SCOPE_GLOBAL: + return ScopeGlobal + default: + return ScopeUnspecified + } +} + +// toProtoRecordStatus converts a domain RecordStatus to its wire enum +// value. +func toProtoRecordStatus(s RecordStatus) memoryv1.RecordStatus { + switch s { + case RecordStatusCanonical: + return memoryv1.RecordStatus_RECORD_STATUS_CANONICAL + case RecordStatusPending: + return memoryv1.RecordStatus_RECORD_STATUS_PENDING + default: + return memoryv1.RecordStatus_RECORD_STATUS_UNSPECIFIED + } +} + +// fromProtoRecordStatus converts a wire RecordStatus enum value to its +// domain equivalent. +func fromProtoRecordStatus(s memoryv1.RecordStatus) RecordStatus { + switch s { + case memoryv1.RecordStatus_RECORD_STATUS_CANONICAL: + return RecordStatusCanonical + case memoryv1.RecordStatus_RECORD_STATUS_PENDING: + return RecordStatusPending + default: + return RecordStatusUnspecified + } +} + +// contentToProto wraps text into the single-element []*ContentBlock the +// wire types carry, per this category's text-only-in-v1 constraint +// (docs/specifications/memory/data-types.md#recallrequest--memoryrecord). +func contentToProto(text string) []*contentv1.ContentBlock { + if text == "" { + return nil + } + return []*contentv1.ContentBlock{content.Text(text)} +} + +// contentFromProto collapses blocks back to a single string, enforcing the +// text-only-in-v1 constraint: any non-text block is a protocol violation +// this SDK rejects rather than silently drops. +func contentFromProto(blocks []*contentv1.ContentBlock) (string, error) { + var text string + for _, b := range blocks { + tb := b.GetText() + if tb == nil { + return "", fmt.Errorf("memory: convert: content block is not text-only, which this category requires in v1") + } + text += tb.GetText() + } + return text, nil +} + +// provenanceToProto converts a domain Provenance to its wire equivalent. +func provenanceToProto(p Provenance) *memoryv1.Provenance { + pb := &memoryv1.Provenance{ + SourceSessionId: p.SourceSessionID, + RecordedBy: p.RecordedBy, + } + if p.SourceTurnID != "" { + turnID := p.SourceTurnID + pb.SourceTurnId = &turnID + } + return pb +} + +// provenanceFromProto converts a wire Provenance to its domain equivalent. +func provenanceFromProto(p *memoryv1.Provenance) Provenance { + return Provenance{ + SourceSessionID: p.GetSourceSessionId(), + SourceTurnID: p.GetSourceTurnId(), + RecordedBy: p.GetRecordedBy(), + } +} + +// recordToProto converts a domain Record to the wire MemoryRecord Recall, +// ListRecords, and GetRecord return. +func recordToProto(r Record) (*memoryv1.MemoryRecord, error) { + pb := &memoryv1.MemoryRecord{ + Id: r.ID, + Type: toProtoMemoryType(r.Type), + Scope: toProtoMemoryScope(r.Scope), + Title: r.Title, + Content: contentToProto(r.Content), + Tokens: int64(r.Tokens), + Status: toProtoRecordStatus(r.Status), + Links: r.Links, + CreatedAt: timestamppb.New(r.CreatedAt), + UpdatedAt: timestamppb.New(r.UpdatedAt), + Provenance: provenanceToProto(r.Provenance), + } + if r.RelevanceScore != nil { + score := *r.RelevanceScore + if score < 0 || score > 1 { + return nil, fmt.Errorf("memory: convert: relevance_score %v is outside the required [0, 1] range", score) + } + pb.RelevanceScore = &score + } + return pb, nil +} + +// recordFromProto converts a wire MemoryRecord to its domain equivalent. +func recordFromProto(pb *memoryv1.MemoryRecord) (Record, error) { + text, err := contentFromProto(pb.GetContent()) + if err != nil { + return Record{}, err + } + + r := Record{ + ID: pb.GetId(), + Type: fromProtoMemoryType(pb.GetType()), + Scope: fromProtoMemoryScope(pb.GetScope()), + Title: pb.GetTitle(), + Content: text, + Tokens: clampInt32(pb.GetTokens()), + Status: fromProtoRecordStatus(pb.GetStatus()), + Links: pb.GetLinks(), + CreatedAt: pb.GetCreatedAt().AsTime(), + UpdatedAt: pb.GetUpdatedAt().AsTime(), + Provenance: provenanceFromProto(pb.GetProvenance()), + } + if pb.RelevanceScore != nil { + score := pb.GetRelevanceScore() + r.RelevanceScore = &score + } + return r, nil +} + +// recordResultToProto converts a domain RecordResult to its wire +// equivalent. +func recordResultToProto(r RecordResult) *memoryv1.RecordResult { + return &memoryv1.RecordResult{ + Id: r.ID, + Status: toProtoRecordStatus(r.Status), + } +} + +// deleteResultToProto converts a domain DeleteResult to its wire +// equivalent. +func deleteResultToProto(r DeleteResult) *memoryv1.DeleteResult { + return &memoryv1.DeleteResult{Deleted: r.Deleted} +} + +// recallRequestFromProto converts a wire RecallRequest to its domain +// equivalent. +func recallRequestFromProto(req *memoryv1.RecallRequest) RecallRequest { + typeFilter := make([]Type, 0, len(req.GetTypeFilter())) + for _, t := range req.GetTypeFilter() { + typeFilter = append(typeFilter, fromProtoMemoryType(t)) + } + scopeFilter := make([]Scope, 0, len(req.GetScopeFilter())) + for _, s := range req.GetScopeFilter() { + scopeFilter = append(scopeFilter, fromProtoMemoryScope(s)) + } + + return RecallRequest{ + SessionID: req.GetSessionId(), + TurnID: req.GetTurnId(), + TokenBudget: req.GetTokenBudget(), + ModelTarget: req.GetModelTarget(), + FilesTouched: req.GetFilesTouched(), + WorkingDirectory: req.GetWorkingDirectory(), + TypeFilter: typeFilter, + ScopeFilter: scopeFilter, + IncludePending: req.GetIncludePending(), + } +} + +// recordRequestFromProto converts a wire RecordRequest to its domain +// equivalent. +func recordRequestFromProto(req *memoryv1.RecordRequest) (RecordRequest, error) { + text, err := contentFromProto(req.GetContent()) + if err != nil { + return RecordRequest{}, err + } + return RecordRequest{ + Type: fromProtoMemoryType(req.GetType()), + Scope: fromProtoMemoryScope(req.GetScope()), + ID: req.GetId(), + Title: req.GetTitle(), + Content: text, + }, nil +} + +// updateRecordRequestFromProto converts a wire UpdateRecordRequest to its +// domain equivalent. The wire message itself carries no type/scope field +// (memory.go's UpdateRecordRequest doc comment explains why), so there is +// nothing to strip here — the immutability guarantee is structural, not +// something this conversion enforces. +func updateRecordRequestFromProto(req *memoryv1.UpdateRecordRequest) (UpdateRecordRequest, error) { + text, err := contentFromProto(req.GetContent()) + if err != nil { + return UpdateRecordRequest{}, err + } + + out := UpdateRecordRequest{ + ID: req.GetId(), + Content: text, + } + if req.Title != nil { + title := req.GetTitle() + out.Title = &title + } + return out, nil +} + +// listRecordsRequestFromProto converts a wire ListRecordsRequest to its +// domain equivalent. +func listRecordsRequestFromProto(req *memoryv1.ListRecordsRequest) ListRecordsRequest { + typeFilter := make([]Type, 0, len(req.GetTypeFilter())) + for _, t := range req.GetTypeFilter() { + typeFilter = append(typeFilter, fromProtoMemoryType(t)) + } + scopeFilter := make([]Scope, 0, len(req.GetScopeFilter())) + for _, s := range req.GetScopeFilter() { + scopeFilter = append(scopeFilter, fromProtoMemoryScope(s)) + } + + out := ListRecordsRequest{ + TypeFilter: typeFilter, + ScopeFilter: scopeFilter, + PageSize: req.GetPageSize(), + PageToken: req.GetPageToken(), + } + if req.StatusFilter != nil { + status := fromProtoRecordStatus(req.GetStatusFilter()) + out.StatusFilter = &status + } + return out +} + +// listRecordsResultToProto converts a domain ListRecordsResult to its wire +// equivalent. +func listRecordsResultToProto(r ListRecordsResult) (*memoryv1.ListRecordsResponse, error) { + records := make([]*memoryv1.MemoryRecord, 0, len(r.Records)) + for _, rec := range r.Records { + pb, err := recordToProto(rec) + if err != nil { + return nil, err + } + records = append(records, pb) + } + return &memoryv1.ListRecordsResponse{ + Records: records, + NextPageToken: r.NextPageToken, + }, nil +} diff --git a/pkg/memory/convert_internal_test.go b/pkg/memory/convert_internal_test.go new file mode 100644 index 0000000..e3e16e1 --- /dev/null +++ b/pkg/memory/convert_internal_test.go @@ -0,0 +1,354 @@ +package memory + +import ( + "testing" + "time" + + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + memoryv1 "github.com/pluggableharness/agent/pkg/memory/proto/v1" +) + +// This file white-box tests convert.go's unexported translation functions +// directly, rather than only through the RPC boundary server_test.go +// exercises — both give confidence, but a direct table test is the more +// natural way to hit every enum branch (including the UNSPECIFIED/default +// zero-value case a real request would never send). + +func TestMemoryTypeConversion(t *testing.T) { + t.Parallel() + + tests := []struct { + domain Type + wire memoryv1.MemoryType + }{ + {TypeUnspecified, memoryv1.MemoryType_MEMORY_TYPE_UNSPECIFIED}, + {TypeUser, memoryv1.MemoryType_MEMORY_TYPE_USER}, + {TypeFeedback, memoryv1.MemoryType_MEMORY_TYPE_FEEDBACK}, + {TypeProject, memoryv1.MemoryType_MEMORY_TYPE_PROJECT}, + {TypeReference, memoryv1.MemoryType_MEMORY_TYPE_REFERENCE}, + } + + for _, tt := range tests { + t.Run(tt.domain.String(), func(t *testing.T) { + t.Parallel() + if got := toProtoMemoryType(tt.domain); got != tt.wire { + t.Errorf("toProtoMemoryType(%v) = %v, want %v", tt.domain, got, tt.wire) + } + if got := fromProtoMemoryType(tt.wire); got != tt.domain { + t.Errorf("fromProtoMemoryType(%v) = %v, want %v", tt.wire, got, tt.domain) + } + }) + } + + if got := fromProtoMemoryType(memoryv1.MemoryType(99)); got != TypeUnspecified { + t.Errorf("fromProtoMemoryType(99) = %v, want TypeUnspecified", got) + } + if got := toProtoMemoryType(Type(99)); got != memoryv1.MemoryType_MEMORY_TYPE_UNSPECIFIED { + t.Errorf("toProtoMemoryType(99) = %v, want MEMORY_TYPE_UNSPECIFIED", got) + } +} + +func TestMemoryScopeConversion(t *testing.T) { + t.Parallel() + + tests := []struct { + domain Scope + wire memoryv1.MemoryScope + }{ + {ScopeUnspecified, memoryv1.MemoryScope_MEMORY_SCOPE_UNSPECIFIED}, + {ScopeSession, memoryv1.MemoryScope_MEMORY_SCOPE_SESSION}, + {ScopeProject, memoryv1.MemoryScope_MEMORY_SCOPE_PROJECT}, + {ScopeGlobal, memoryv1.MemoryScope_MEMORY_SCOPE_GLOBAL}, + } + + for _, tt := range tests { + t.Run(tt.domain.String(), func(t *testing.T) { + t.Parallel() + if got := toProtoMemoryScope(tt.domain); got != tt.wire { + t.Errorf("toProtoMemoryScope(%v) = %v, want %v", tt.domain, got, tt.wire) + } + if got := fromProtoMemoryScope(tt.wire); got != tt.domain { + t.Errorf("fromProtoMemoryScope(%v) = %v, want %v", tt.wire, got, tt.domain) + } + }) + } + + if got := fromProtoMemoryScope(memoryv1.MemoryScope(99)); got != ScopeUnspecified { + t.Errorf("fromProtoMemoryScope(99) = %v, want ScopeUnspecified", got) + } + if got := toProtoMemoryScope(Scope(99)); got != memoryv1.MemoryScope_MEMORY_SCOPE_UNSPECIFIED { + t.Errorf("toProtoMemoryScope(99) = %v, want MEMORY_SCOPE_UNSPECIFIED", got) + } +} + +func TestRecordStatusConversion(t *testing.T) { + t.Parallel() + + tests := []struct { + domain RecordStatus + wire memoryv1.RecordStatus + }{ + {RecordStatusUnspecified, memoryv1.RecordStatus_RECORD_STATUS_UNSPECIFIED}, + {RecordStatusCanonical, memoryv1.RecordStatus_RECORD_STATUS_CANONICAL}, + {RecordStatusPending, memoryv1.RecordStatus_RECORD_STATUS_PENDING}, + } + + for _, tt := range tests { + t.Run(tt.domain.String(), func(t *testing.T) { + t.Parallel() + if got := toProtoRecordStatus(tt.domain); got != tt.wire { + t.Errorf("toProtoRecordStatus(%v) = %v, want %v", tt.domain, got, tt.wire) + } + if got := fromProtoRecordStatus(tt.wire); got != tt.domain { + t.Errorf("fromProtoRecordStatus(%v) = %v, want %v", tt.wire, got, tt.domain) + } + }) + } + + if got := fromProtoRecordStatus(memoryv1.RecordStatus(99)); got != RecordStatusUnspecified { + t.Errorf("fromProtoRecordStatus(99) = %v, want RecordStatusUnspecified", got) + } +} + +func TestContentConversion(t *testing.T) { + t.Parallel() + + t.Run("empty text yields nil blocks", func(t *testing.T) { + t.Parallel() + if got := contentToProto(""); got != nil { + t.Errorf("contentToProto(\"\") = %v, want nil", got) + } + }) + + t.Run("round trip", func(t *testing.T) { + t.Parallel() + blocks := contentToProto("hello world") + text, err := contentFromProto(blocks) + if err != nil { + t.Fatalf("contentFromProto() error = %v, want nil", err) + } + if text != "hello world" { + t.Errorf("contentFromProto() = %q, want %q", text, "hello world") + } + }) + + t.Run("empty blocks yield empty text", func(t *testing.T) { + t.Parallel() + text, err := contentFromProto(nil) + if err != nil { + t.Fatalf("contentFromProto(nil) error = %v, want nil", err) + } + if text != "" { + t.Errorf("contentFromProto(nil) = %q, want \"\"", text) + } + }) + + t.Run("non-text block is rejected", func(t *testing.T) { + t.Parallel() + blocks := []*contentv1.ContentBlock{{ + Block: &contentv1.ContentBlock_Image{Image: &contentv1.ImageBlock{Data: []byte("x"), MediaType: "image/png"}}, + }} + if _, err := contentFromProto(blocks); err == nil { + t.Error("contentFromProto() error = nil, want a text-only-in-v1 rejection") + } + }) +} + +func TestProvenanceConversion(t *testing.T) { + t.Parallel() + + t.Run("with turn id", func(t *testing.T) { + t.Parallel() + in := Provenance{SourceSessionID: "s1", SourceTurnID: "t1", RecordedBy: "memory.remember"} + out := provenanceFromProto(provenanceToProto(in)) + if out != in { + t.Errorf("round trip = %+v, want %+v", out, in) + } + }) + + t.Run("without turn id", func(t *testing.T) { + t.Parallel() + in := Provenance{SourceSessionID: "s1", RecordedBy: "memory.remember"} + pb := provenanceToProto(in) + if pb.SourceTurnId != nil { + t.Errorf("SourceTurnId = %q, want unset", pb.GetSourceTurnId()) + } + out := provenanceFromProto(pb) + if out != in { + t.Errorf("round trip = %+v, want %+v", out, in) + } + }) + + t.Run("nil proto", func(t *testing.T) { + t.Parallel() + out := provenanceFromProto(nil) + if out != (Provenance{}) { + t.Errorf("provenanceFromProto(nil) = %+v, want zero value", out) + } + }) +} + +func TestRecordConversion(t *testing.T) { + t.Parallel() + + t.Run("round trip", func(t *testing.T) { + t.Parallel() + now := time.Now().UTC().Truncate(time.Second) + score := 0.5 + in := Record{ + ID: "r1", + Type: TypeFeedback, + Scope: ScopeSession, + Title: "t", + Content: "c", + Tokens: 3, + Status: RecordStatusCanonical, + Links: []string{"a", "b"}, + CreatedAt: now, + UpdatedAt: now, + Provenance: Provenance{SourceSessionID: "s1", RecordedBy: "x"}, + RelevanceScore: &score, + } + pb, err := recordToProto(in) + if err != nil { + t.Fatalf("recordToProto() error = %v, want nil", err) + } + out, err := recordFromProto(pb) + if err != nil { + t.Fatalf("recordFromProto() error = %v, want nil", err) + } + if out.ID != in.ID || out.Type != in.Type || out.Scope != in.Scope || out.Content != in.Content { + t.Errorf("round trip = %+v, want %+v", out, in) + } + if out.RelevanceScore == nil || *out.RelevanceScore != score { + t.Errorf("RelevanceScore = %v, want %v", out.RelevanceScore, score) + } + if !out.CreatedAt.Equal(now) { + t.Errorf("CreatedAt = %v, want %v", out.CreatedAt, now) + } + }) + + t.Run("relevance score out of range is rejected", func(t *testing.T) { + t.Parallel() + tooLow := -0.1 + _, err := recordToProto(Record{RelevanceScore: &tooLow}) + if err == nil { + t.Error("recordToProto() error = nil, want an out-of-range rejection") + } + }) + + t.Run("non-text content is rejected on the way back", func(t *testing.T) { + t.Parallel() + pb := &memoryv1.MemoryRecord{Content: []*contentv1.ContentBlock{{ + Block: &contentv1.ContentBlock_Image{Image: &contentv1.ImageBlock{}}, + }}} + if _, err := recordFromProto(pb); err == nil { + t.Error("recordFromProto() error = nil, want a text-only-in-v1 rejection") + } + }) +} + +func TestRecordResultAndDeleteResultConversion(t *testing.T) { + t.Parallel() + + rr := recordResultToProto(RecordResult{ID: "r1", Status: RecordStatusPending}) + if rr.GetId() != "r1" || rr.GetStatus() != memoryv1.RecordStatus_RECORD_STATUS_PENDING { + t.Errorf("recordResultToProto() = %+v, want id r1 / PENDING", rr) + } + + dr := deleteResultToProto(DeleteResult{Deleted: true}) + if !dr.GetDeleted() { + t.Error("deleteResultToProto().Deleted = false, want true") + } +} + +func TestRecordRequestFromProto(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + id := "r1" + pb := &memoryv1.RecordRequest{ + Type: memoryv1.MemoryType_MEMORY_TYPE_USER, + Scope: memoryv1.MemoryScope_MEMORY_SCOPE_GLOBAL, + Id: &id, + Title: "t", + Content: contentToProto("c"), + } + out, err := recordRequestFromProto(pb) + if err != nil { + t.Fatalf("recordRequestFromProto() error = %v, want nil", err) + } + if out.ID != "r1" || out.Type != TypeUser || out.Scope != ScopeGlobal || out.Content != "c" { + t.Errorf("recordRequestFromProto() = %+v", out) + } + }) + + t.Run("non-text content is rejected", func(t *testing.T) { + t.Parallel() + pb := &memoryv1.RecordRequest{Content: []*contentv1.ContentBlock{{ + Block: &contentv1.ContentBlock_Image{Image: &contentv1.ImageBlock{}}, + }}} + if _, err := recordRequestFromProto(pb); err == nil { + t.Error("recordRequestFromProto() error = nil, want a rejection") + } + }) +} + +func TestUpdateRecordRequestFromProto_NonTextRejected(t *testing.T) { + t.Parallel() + + pb := &memoryv1.UpdateRecordRequest{Content: []*contentv1.ContentBlock{{ + Block: &contentv1.ContentBlock_Image{Image: &contentv1.ImageBlock{}}, + }}} + if _, err := updateRecordRequestFromProto(pb); err == nil { + t.Error("updateRecordRequestFromProto() error = nil, want a rejection") + } +} + +func TestListRecordsRequestFromProto_NoStatusFilter(t *testing.T) { + t.Parallel() + + out := listRecordsRequestFromProto(&memoryv1.ListRecordsRequest{PageSize: 10, PageToken: "tok"}) + if out.StatusFilter != nil { + t.Errorf("StatusFilter = %v, want nil", out.StatusFilter) + } + if out.PageSize != 10 || out.PageToken != "tok" { + t.Errorf("ListRecordsRequest = %+v", out) + } +} + +func TestListRecordsResultToProto_PropagatesConversionError(t *testing.T) { + t.Parallel() + + tooHigh := 2.0 + _, err := listRecordsResultToProto(ListRecordsResult{Records: []Record{{RelevanceScore: &tooHigh}}}) + if err == nil { + t.Error("listRecordsResultToProto() error = nil, want the out-of-range rejection to propagate") + } +} + +func TestCapabilitiesToProto(t *testing.T) { + t.Parallel() + + caps := Capabilities{ + DefaultTokenBudget: 1000, + SupportedTypes: []Type{TypeUser, TypeProject}, + SupportedScopes: []Scope{ScopeGlobal}, + RatificationSupported: true, + SupportedHookPoints: nil, + } + pb := capabilitiesToProto(caps) + if pb.GetDefaultTokenBudget() != 1000 { + t.Errorf("DefaultTokenBudget = %d, want 1000", pb.GetDefaultTokenBudget()) + } + if len(pb.GetSupportedTypes()) != 2 { + t.Errorf("SupportedTypes = %v, want 2 entries", pb.GetSupportedTypes()) + } + if len(pb.GetSupportedScopes()) != 1 { + t.Errorf("SupportedScopes = %v, want 1 entry", pb.GetSupportedScopes()) + } + if !pb.GetRatificationSupported() { + t.Error("RatificationSupported = false, want true") + } +} diff --git a/pkg/memory/convert_test.go b/pkg/memory/convert_test.go new file mode 100644 index 0000000..4d1fb10 --- /dev/null +++ b/pkg/memory/convert_test.go @@ -0,0 +1,224 @@ +package memory_test + +import ( + "context" + "testing" + "time" + + "google.golang.org/grpc/codes" + + "github.com/pluggableharness/agent/pkg/memory" + memoryv1 "github.com/pluggableharness/agent/pkg/memory/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" +) + +// exerciseRecordRoundTrip drives a Record through the wire boundary (via a +// Recall call) and returns what came back, black-box — convert.go's +// recordToProto/recordFromProto aren't exported, so this package tests them +// through the public RPC surface, same as every other _test.go in this +// package. +func exerciseRecordRoundTrip(t *testing.T, in memory.Record) *memoryv1.MemoryRecord { + t.Helper() + + provider := &fakeProvider{recallFunc: func(_ context.Context, _ memory.RecallRequest) (memory.RecallResult, error) { + return memory.RecallResult{Records: []memory.Record{in}}, nil + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + resp, err := client.Recall(t.Context(), &memoryv1.RecallRequest{}) + if err != nil { + t.Fatalf("Recall() error = %v, want nil", err) + } + if len(resp.GetRecords()) != 1 { + t.Fatalf("Recall() returned %d records, want 1", len(resp.GetRecords())) + } + return resp.GetRecords()[0] +} + +func TestConvert_RecordRoundTrip(t *testing.T) { + t.Parallel() + + now := time.Now().UTC().Truncate(time.Second) + score := 0.75 + in := memory.Record{ + ID: "user-role", + Type: memory.TypeUser, + Scope: memory.ScopeGlobal, + Title: "Role", + Content: "Backend engineer.", + Tokens: 42, + Status: memory.RecordStatusCanonical, + Links: []string{"other-record"}, + CreatedAt: now, + UpdatedAt: now, + Provenance: memory.Provenance{SourceSessionID: "sess-1", SourceTurnID: "turn-1", RecordedBy: "memory.remember"}, + RelevanceScore: &score, + } + + out := exerciseRecordRoundTrip(t, in) + + if out.GetId() != in.ID { + t.Errorf("Id = %q, want %q", out.GetId(), in.ID) + } + if out.GetType() != memoryv1.MemoryType_MEMORY_TYPE_USER { + t.Errorf("Type = %v, want MEMORY_TYPE_USER", out.GetType()) + } + if out.GetScope() != memoryv1.MemoryScope_MEMORY_SCOPE_GLOBAL { + t.Errorf("Scope = %v, want MEMORY_SCOPE_GLOBAL", out.GetScope()) + } + if out.GetTitle() != in.Title { + t.Errorf("Title = %q, want %q", out.GetTitle(), in.Title) + } + if got := contentText(t, out); got != in.Content { + t.Errorf("Content = %q, want %q", got, in.Content) + } + if out.GetTokens() != int64(in.Tokens) { + t.Errorf("Tokens = %d, want %d", out.GetTokens(), in.Tokens) + } + if out.GetStatus() != memoryv1.RecordStatus_RECORD_STATUS_CANONICAL { + t.Errorf("Status = %v, want RECORD_STATUS_CANONICAL", out.GetStatus()) + } + if len(out.GetLinks()) != 1 || out.GetLinks()[0] != "other-record" { + t.Errorf("Links = %v, want [other-record]", out.GetLinks()) + } + if !out.GetCreatedAt().AsTime().Equal(now) { + t.Errorf("CreatedAt = %v, want %v", out.GetCreatedAt().AsTime(), now) + } + if out.GetProvenance().GetSourceSessionId() != "sess-1" { + t.Errorf("Provenance.SourceSessionId = %q, want %q", out.GetProvenance().GetSourceSessionId(), "sess-1") + } + if out.GetProvenance().GetSourceTurnId() != "turn-1" { + t.Errorf("Provenance.SourceTurnId = %q, want %q", out.GetProvenance().GetSourceTurnId(), "turn-1") + } + if out.GetRelevanceScore() != 0.75 { + t.Errorf("RelevanceScore = %v, want 0.75", out.GetRelevanceScore()) + } +} + +func TestConvert_RecordRoundTrip_NoRelevanceScoreOrTurnID(t *testing.T) { + t.Parallel() + + in := memory.Record{ID: "r1", Type: memory.TypeProject, Scope: memory.ScopeProject, Content: "note"} + out := exerciseRecordRoundTrip(t, in) + + if out.RelevanceScore != nil { + t.Errorf("RelevanceScore = %v, want nil", out.GetRelevanceScore()) + } + if out.GetProvenance().SourceTurnId != nil { + t.Errorf("Provenance.SourceTurnId = %q, want unset", out.GetProvenance().GetSourceTurnId()) + } +} + +func TestConvert_RelevanceScoreOutOfRangeRejected(t *testing.T) { + t.Parallel() + + tooHigh := 1.5 + provider := &fakeProvider{recallFunc: func(context.Context, memory.RecallRequest) (memory.RecallResult, error) { + return memory.RecallResult{Records: []memory.Record{{ID: "r1", RelevanceScore: &tooHigh}}}, nil + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.Recall(t.Context(), &memoryv1.RecallRequest{}) + assertCode(t, err, codes.Internal) +} + +func TestConvert_UpdateRecordRequest_TitleOptional(t *testing.T) { + t.Parallel() + + t.Run("unset title leaves Title nil", func(t *testing.T) { + t.Parallel() + var gotTitle *string + provider := &fakeProvider{updateRecordFunc: func(_ context.Context, req memory.UpdateRecordRequest) (memory.RecordResult, error) { + gotTitle = req.Title + return memory.RecordResult{ID: req.ID}, nil + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.UpdateRecord(t.Context(), &memoryv1.UpdateRecordRequest{Id: "r1", Content: textBlocks("x")}) + if err != nil { + t.Fatalf("UpdateRecord() error = %v, want nil", err) + } + if gotTitle != nil { + t.Errorf("Title = %q, want nil", *gotTitle) + } + }) + + t.Run("set title round-trips", func(t *testing.T) { + t.Parallel() + var gotTitle *string + provider := &fakeProvider{updateRecordFunc: func(_ context.Context, req memory.UpdateRecordRequest) (memory.RecordResult, error) { + gotTitle = req.Title + return memory.RecordResult{ID: req.ID}, nil + }} + newTitle := "New Title" + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.UpdateRecord(t.Context(), &memoryv1.UpdateRecordRequest{Id: "r1", Title: &newTitle, Content: textBlocks("x")}) + if err != nil { + t.Fatalf("UpdateRecord() error = %v, want nil", err) + } + if gotTitle == nil || *gotTitle != newTitle { + t.Errorf("Title = %v, want %q", gotTitle, newTitle) + } + }) +} + +func TestConvert_ListRecordsRequest_StatusFilter(t *testing.T) { + t.Parallel() + + var gotFilter *memory.RecordStatus + provider := &fakeProvider{listRecordsFunc: func(_ context.Context, req memory.ListRecordsRequest) (memory.ListRecordsResult, error) { + gotFilter = req.StatusFilter + return memory.ListRecordsResult{}, nil + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + pending := memoryv1.RecordStatus_RECORD_STATUS_PENDING + _, err := client.ListRecords(t.Context(), &memoryv1.ListRecordsRequest{StatusFilter: &pending}) + if err != nil { + t.Fatalf("ListRecords() error = %v, want nil", err) + } + if gotFilter == nil || *gotFilter != memory.RecordStatusPending { + t.Errorf("StatusFilter = %v, want RecordStatusPending", gotFilter) + } +} + +func TestConvert_RecallRequest_Filters(t *testing.T) { + t.Parallel() + + var got memory.RecallRequest + provider := &fakeProvider{recallFunc: func(_ context.Context, req memory.RecallRequest) (memory.RecallResult, error) { + got = req + return memory.RecallResult{}, nil + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.Recall(t.Context(), &memoryv1.RecallRequest{ + SessionId: "sess-1", + TurnId: "turn-1", + TokenBudget: 500, + TypeFilter: []memoryv1.MemoryType{memoryv1.MemoryType_MEMORY_TYPE_PROJECT}, + ScopeFilter: []memoryv1.MemoryScope{memoryv1.MemoryScope_MEMORY_SCOPE_PROJECT}, + IncludePending: true, + }) + if err != nil { + t.Fatalf("Recall() error = %v, want nil", err) + } + if got.SessionID != "sess-1" || got.TurnID != "turn-1" || got.TokenBudget != 500 { + t.Errorf("RecallRequest = %+v, want session/turn/budget to round-trip", got) + } + if len(got.TypeFilter) != 1 || got.TypeFilter[0] != memory.TypeProject { + t.Errorf("TypeFilter = %v, want [TypeProject]", got.TypeFilter) + } + if len(got.ScopeFilter) != 1 || got.ScopeFilter[0] != memory.ScopeProject { + t.Errorf("ScopeFilter = %v, want [ScopeProject]", got.ScopeFilter) + } + if !got.IncludePending { + t.Error("IncludePending = false, want true") + } +} + +// contentText extracts the plain text of r's content, failing t if it +// isn't exactly one text block. +func contentText(t *testing.T, r *memoryv1.MemoryRecord) string { + t.Helper() + blocks := r.GetContent() + if len(blocks) != 1 || blocks[0].GetText() == nil { + t.Fatalf("content = %v, want exactly one text block", blocks) + } + return blocks[0].GetText().GetText() +} diff --git a/pkg/memory/doc.go b/pkg/memory/doc.go new file mode 100644 index 0000000..fcb90b1 --- /dev/null +++ b/pkg/memory/doc.go @@ -0,0 +1,58 @@ +// Package memory is the hand-written, plugin-author-facing SDK for the +// memory provider category — plugins that persist knowledge across +// sessions and recall it into future ones +// (docs/specifications/memory/README.md). A memory provider plugin exposes +// nine MUST RPCs (GetCapabilities, Configure, Recall, Record, UpdateRecord, +// DeleteRecord, ListRecords, GetRecord, Describe) plus two optional +// surfaces: the ratification pattern (ApproveRecord/RejectRecord, MAY +// together, never alone — docs/specifications/memory/protocol.md#ratification-optional) +// and Render (docs/specifications/memory/protocol.md#render). All twelve +// RPCs are unary — nothing in this category streams +// (docs/specifications/memory/README.md#transport--lifecycle). +// +// # Domain types, not raw generated types +// +// A plugin author implements Provider (and, optionally, +// RatificationProvider and Renderer) against the domain types declared in +// this file — Type, Scope, RecordStatus, Record, Provenance, +// Capabilities, and the per-RPC request/result shapes — rather than the +// generated pkg/memory/proto/v1 wire types directly. convert.go translates +// between the two, both directions, so an author never has to reach for a +// protobuf-generated enum constant or build a []*contentv1.ContentBlock by +// hand for the text-only-in-v1 content this category carries +// (docs/specifications/memory/data-types.md#recallrequest--memoryrecord). +// +// server.go's Service adapts a Provider into the generated +// memoryv1.MemoryServiceServer, satisfying pkg/plugin.Service so a plugin +// author's main() can pass it straight to plugin.Config.Services. +// +// # The fixed taxonomy +// +// Type (user/feedback/project/reference) and Scope +// (session/project/global) are fixed at the protocol level, not +// provider-defined (docs/specifications/memory/taxonomy.md). A provider MAY +// support only a subset of either, declared exactly via +// Capabilities.SupportedTypes/SupportedScopes. Both fields are immutable on +// a Record once created — UpdateRecordRequest deliberately carries no way +// to set either, enforcing that MUST at the Go type level rather than by +// runtime validation alone (docs/specifications/memory/protocol.md#record-updaterecord-deleterecord-the-write-side). +// +// # Ratification is both-or-neither, enforced structurally +// +// RatificationProvider embeds Provider and adds ApproveRecord/RejectRecord. +// Because Go interface satisfaction is all-or-nothing, a Provider +// implementation either satisfies RatificationProvider in full or not at +// all — there is no way to "implement only one" and have server.go treat it +// as ratification-capable. NewService performs the type assertion once at +// construction and uses that result, not whatever a Provider's own +// Capabilities.RatificationSupported claims, as the authoritative signal +// wired into the outgoing GetCapabilities response and into ApproveRecord/ +// RejectRecord routing — see server.go's NewService doc comment. +// +// # Token counting is a kernel callback, never a local heuristic +// +// MemoryRecord.Tokens MUST be computed via the kernel's CountTokens +// callback (docs/specifications/kernel-callbacks.md#counttokens), never a +// provider-local heuristic. CountTokens in this package is the obvious, +// hard-to-avoid call for that. +package memory diff --git a/pkg/memory/errors.go b/pkg/memory/errors.go new file mode 100644 index 0000000..2ebe541 --- /dev/null +++ b/pkg/memory/errors.go @@ -0,0 +1,179 @@ +package memory + +import ( + "fmt" + + "google.golang.org/grpc/codes" + + "github.com/pluggableharness/agent/pkg/plugin" +) + +// errorDomain identifies this category's error taxonomy in the +// google.rpc.ErrorInfo structured detail plugin.StatusError attaches to +// every RPC error crossing the plugin boundary +// (.claude/rules/grpc.md#error-taxonomy--codes). +const errorDomain = "memory.pluggableharness.dev" + +// ErrorCategory is the structured error taxonomy every *Error classifies +// into (docs/specifications/memory/data-types.md#memoryerror, +// docs/specifications/memory/conformance.md#error-taxonomy). A Provider +// implementation MUST classify every failure into one of these and MUST +// NOT collapse them into a single generic error. +type ErrorCategory int + +// The fixed MemoryErrorCategory taxonomy. +const ( + // ErrorCategoryUnspecified is never valid on a real error; its + // presence means a caller forgot to set the field. + ErrorCategoryUnspecified ErrorCategory = iota + // ErrorCategoryNotFound: UpdateRecord/DeleteRecord/ApproveRecord/ + // RejectRecord/GetRecord referenced an id that doesn't exist. + ErrorCategoryNotFound + // ErrorCategoryInvalidType: Record specified a Type this + // provider doesn't support. + ErrorCategoryInvalidType + // ErrorCategoryInvalidScope: Record specified a Scope this + // provider doesn't support. + ErrorCategoryInvalidScope + // ErrorCategoryRatificationUnsupported: ApproveRecord/RejectRecord was + // called against a provider with RatificationSupported == false. + ErrorCategoryRatificationUnsupported + // ErrorCategoryBudgetExceeded: Recall's candidate records exceed + // token_budget even after this provider's own truncation. + ErrorCategoryBudgetExceeded + // ErrorCategorySourceUnavailable: this provider's backend storage was + // unreachable at call time. A retry candidate. + ErrorCategorySourceUnavailable + // ErrorCategoryUnknown covers anything not more specifically + // categorized above. + ErrorCategoryUnknown +) + +// String returns c's taxonomy name, e.g. "not_found". +func (c ErrorCategory) String() string { + switch c { + case ErrorCategoryNotFound: + return "not_found" + case ErrorCategoryInvalidType: + return "invalid_type" + case ErrorCategoryInvalidScope: + return "invalid_scope" + case ErrorCategoryRatificationUnsupported: + return "ratification_unsupported" + case ErrorCategoryBudgetExceeded: + return "budget_exceeded" + case ErrorCategorySourceUnavailable: + return "source_unavailable" + case ErrorCategoryUnknown: + return "unknown" + default: + return "unspecified" + } +} + +// grpcCode maps c to the gRPC status code +// docs/specifications/memory/conformance.md#error-taxonomy mandates, +// verbatim — never codes.Unknown, per .claude/rules/grpc.md. +func (c ErrorCategory) grpcCode() codes.Code { + switch c { + case ErrorCategoryNotFound: + return codes.NotFound + case ErrorCategoryInvalidType, ErrorCategoryInvalidScope: + return codes.InvalidArgument + case ErrorCategoryRatificationUnsupported: + return codes.FailedPrecondition + case ErrorCategoryBudgetExceeded: + return codes.ResourceExhausted + case ErrorCategorySourceUnavailable: + return codes.Unavailable + default: + return codes.Internal + } +} + +// Error is the structured error every Provider method MUST return instead +// of an opaque error when a failure falls into one of the taxonomy +// categories above (docs/specifications/memory/data-types.md#memoryerror). +// server.go converts an *Error into the corresponding gRPC status via +// plugin.StatusError; any other error type crossing an RPC boundary is +// reported as ErrorCategoryUnknown / codes.Internal. +type Error struct { + // Category classifies the failure. + Category ErrorCategory + // Message is a human-readable error detail. + Message string + // Retryable reports whether the kernel MAY retry this call as-is. + Retryable bool +} + +// Error implements the error interface. +func (e *Error) Error() string { + return fmt.Sprintf("memory: %s: %s", e.Category, e.Message) +} + +// grpcStatus converts e into a *status.Status-backed error via +// plugin.StatusError, carrying e.Category's gRPC code and a structured +// google.rpc.ErrorInfo detail. +func (e *Error) grpcStatus() error { + metadata := map[string]string{"retryable": fmt.Sprintf("%t", e.Retryable)} + return plugin.StatusError(e.Category.grpcCode(), errorDomain, e.Category.String(), e.Message, metadata) +} + +// NotFound builds an *Error{Category: ErrorCategoryNotFound}, non-retryable +// — the obvious call for UpdateRecord/DeleteRecord/ApproveRecord/ +// RejectRecord/GetRecord when id doesn't match an existing record +// (docs/specifications/memory/conformance.md#error-taxonomy: "Surface +// distinctly — a caller passed a stale or wrong id, not a transient +// failure"). +func NotFound(message string) *Error { + return &Error{Category: ErrorCategoryNotFound, Message: message, Retryable: false} +} + +// InvalidType builds an *Error{Category: ErrorCategoryInvalidType}, +// non-retryable — Record specified a Type absent from +// Capabilities.SupportedTypes. +func InvalidType(message string) *Error { + return &Error{Category: ErrorCategoryInvalidType, Message: message, Retryable: false} +} + +// InvalidScope builds an *Error{Category: ErrorCategoryInvalidScope}, +// non-retryable — Record specified a Scope absent from +// Capabilities.SupportedScopes. +func InvalidScope(message string) *Error { + return &Error{Category: ErrorCategoryInvalidScope, Message: message, Retryable: false} +} + +// RatificationUnsupported builds an +// *Error{Category: ErrorCategoryRatificationUnsupported}, non-retryable — +// ApproveRecord/RejectRecord was called against a provider that doesn't +// support ratification. server.go returns this automatically when the +// provider doesn't satisfy RatificationProvider; a Provider author does +// not normally need to construct this directly. +func RatificationUnsupported(message string) *Error { + return &Error{Category: ErrorCategoryRatificationUnsupported, Message: message, Retryable: false} +} + +// BudgetExceeded builds an *Error{Category: ErrorCategoryBudgetExceeded}, +// non-retryable (the caller must resubmit with a different budget, not +// retry the identical call) — Recall's candidate records still exceed +// token_budget after this provider's own truncation. +func BudgetExceeded(message string) *Error { + return &Error{Category: ErrorCategoryBudgetExceeded, Message: message, Retryable: false} +} + +// SourceUnavailable builds an +// *Error{Category: ErrorCategorySourceUnavailable}, retryable — this +// provider's backend storage was unreachable at call time +// (docs/specifications/memory/conformance.md#error-taxonomy: "Retry +// candidate — transient by nature"). +func SourceUnavailable(message string) *Error { + return &Error{Category: ErrorCategorySourceUnavailable, Message: message, Retryable: true} +} + +// Unknown builds an *Error{Category: ErrorCategoryUnknown}, non-retryable +// by default — anything not covered by a more specific category above. +// message MUST include enough detail for debugging +// (docs/specifications/memory/conformance.md#error-taxonomy). +func Unknown(message string) *Error { + return &Error{Category: ErrorCategoryUnknown, Message: message, Retryable: false} +} diff --git a/pkg/memory/errors_internal_test.go b/pkg/memory/errors_internal_test.go new file mode 100644 index 0000000..e62b395 --- /dev/null +++ b/pkg/memory/errors_internal_test.go @@ -0,0 +1,45 @@ +package memory + +import ( + "testing" + + "google.golang.org/grpc/codes" +) + +func TestErrorCategory_GRPCCode(t *testing.T) { + t.Parallel() + + tests := []struct { + category ErrorCategory + want codes.Code + }{ + {ErrorCategoryUnspecified, codes.Internal}, + {ErrorCategoryNotFound, codes.NotFound}, + {ErrorCategoryInvalidType, codes.InvalidArgument}, + {ErrorCategoryInvalidScope, codes.InvalidArgument}, + {ErrorCategoryRatificationUnsupported, codes.FailedPrecondition}, + {ErrorCategoryBudgetExceeded, codes.ResourceExhausted}, + {ErrorCategorySourceUnavailable, codes.Unavailable}, + {ErrorCategoryUnknown, codes.Internal}, + {ErrorCategory(99), codes.Internal}, + } + + for _, tt := range tests { + t.Run(tt.category.String(), func(t *testing.T) { + t.Parallel() + if got := tt.category.grpcCode(); got != tt.want { + t.Errorf("grpcCode() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestError_GRPCStatus(t *testing.T) { + t.Parallel() + + err := &Error{Category: ErrorCategoryBudgetExceeded, Message: "too much", Retryable: false} + grpcErr := err.grpcStatus() + if grpcErr == nil { + t.Fatal("grpcStatus() = nil, want a status error") + } +} diff --git a/pkg/memory/errors_test.go b/pkg/memory/errors_test.go new file mode 100644 index 0000000..99d6702 --- /dev/null +++ b/pkg/memory/errors_test.go @@ -0,0 +1,130 @@ +package memory_test + +import ( + "context" + "testing" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/pluggableharness/agent/pkg/memory" + memoryv1 "github.com/pluggableharness/agent/pkg/memory/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" +) + +func TestErrorCategory_String(t *testing.T) { + t.Parallel() + + tests := []struct { + category memory.ErrorCategory + want string + }{ + {memory.ErrorCategoryUnspecified, "unspecified"}, + {memory.ErrorCategoryNotFound, "not_found"}, + {memory.ErrorCategoryInvalidType, "invalid_type"}, + {memory.ErrorCategoryInvalidScope, "invalid_scope"}, + {memory.ErrorCategoryRatificationUnsupported, "ratification_unsupported"}, + {memory.ErrorCategoryBudgetExceeded, "budget_exceeded"}, + {memory.ErrorCategorySourceUnavailable, "source_unavailable"}, + {memory.ErrorCategoryUnknown, "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + t.Parallel() + if got := tt.category.String(); got != tt.want { + t.Errorf("String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestError_Error(t *testing.T) { + t.Parallel() + + err := memory.NotFound("no such record") + want := "memory: not_found: no such record" + if got := err.Error(); got != want { + t.Errorf("Error() = %q, want %q", got, want) + } +} + +// TestErrorMapping_ThroughRPCBoundary drives every constructor through a +// real RPC round trip (via GetRecord, which the empty-id short-circuit in +// server.go doesn't intercept once an id is supplied) and asserts the +// resulting gRPC code matches conformance.md's table exactly — the mapping +// this package's whole raison d'être depends on getting right. +func TestErrorMapping_ThroughRPCBoundary(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + build func() *memory.Error + wantCode codes.Code + wantRetry bool + }{ + {"not_found", func() *memory.Error { return memory.NotFound("x") }, codes.NotFound, false}, + {"invalid_type", func() *memory.Error { return memory.InvalidType("x") }, codes.InvalidArgument, false}, + {"invalid_scope", func() *memory.Error { return memory.InvalidScope("x") }, codes.InvalidArgument, false}, + {"ratification_unsupported", func() *memory.Error { return memory.RatificationUnsupported("x") }, codes.FailedPrecondition, false}, + {"budget_exceeded", func() *memory.Error { return memory.BudgetExceeded("x") }, codes.ResourceExhausted, false}, + {"source_unavailable", func() *memory.Error { return memory.SourceUnavailable("x") }, codes.Unavailable, true}, + {"unknown", func() *memory.Error { return memory.Unknown("x") }, codes.Internal, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + domainErr := tt.build() + provider := &fakeProvider{getRecordFunc: func(context.Context, string) (memory.Record, error) { + return memory.Record{}, domainErr + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.GetRecord(t.Context(), &memoryv1.GetRecordRequest{Id: "r1"}) + + st, ok := status.FromError(err) + if !ok { + t.Fatalf("error = %v, not a gRPC status error", err) + } + if st.Code() != tt.wantCode { + t.Errorf("code = %v, want %v", st.Code(), tt.wantCode) + } + + var info *errdetails.ErrorInfo + for _, d := range st.Details() { + if ei, ok := d.(*errdetails.ErrorInfo); ok { + info = ei + } + } + if info == nil { + t.Fatalf("status has no ErrorInfo detail") + } + if info.GetDomain() != "memory.pluggableharness.dev" { + t.Errorf("ErrorInfo.Domain = %q, want %q", info.GetDomain(), "memory.pluggableharness.dev") + } + if info.GetReason() != domainErr.Category.String() { + t.Errorf("ErrorInfo.Reason = %q, want %q", info.GetReason(), domainErr.Category.String()) + } + wantRetryable := "false" + if tt.wantRetry { + wantRetryable = "true" + } + if got := info.GetMetadata()["retryable"]; got != wantRetryable { + t.Errorf("ErrorInfo.Metadata[retryable] = %q, want %q", got, wantRetryable) + } + }) + } +} + +func TestErrorMapping_PlainErrorIsUnknownInternal(t *testing.T) { + t.Parallel() + + provider := &fakeProvider{getRecordFunc: func(context.Context, string) (memory.Record, error) { + return memory.Record{}, errInjected + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.GetRecord(t.Context(), &memoryv1.GetRecordRequest{Id: "r1"}) + assertCode(t, err, codes.Internal) +} diff --git a/pkg/memory/helpers_test.go b/pkg/memory/helpers_test.go new file mode 100644 index 0000000..9d53d34 --- /dev/null +++ b/pkg/memory/helpers_test.go @@ -0,0 +1,186 @@ +package memory_test + +import ( + "context" + "net" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/pluggableharness/agent/pkg/content" + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + "github.com/pluggableharness/agent/pkg/memory" + memoryv1 "github.com/pluggableharness/agent/pkg/memory/proto/v1" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// textBlocks builds the single-element []*ContentBlock a wire request +// carries for text s, mirroring convert.go's contentToProto. +func textBlocks(s string) []*contentv1.ContentBlock { + return []*contentv1.ContentBlock{content.Text(s)} +} + +// imageBlocks builds a non-text []*ContentBlock, used to exercise +// contentFromProto's text-only-in-v1 rejection. +func imageBlocks() []*contentv1.ContentBlock { + return []*contentv1.ContentBlock{content.Image([]byte("fake-image-bytes"), "image/png")} +} + +// fakeProvider is a hand-written memory.Provider fake (go-testing.md: +// fakes, not mocking frameworks, per repo convention). Each method's +// behavior is controlled by a caller-set func field; a nil field returns +// its type's zero value and a nil error. +type fakeProvider struct { + capabilitiesFunc func(context.Context) (memory.Capabilities, error) + configureFunc func(context.Context, *structpb.Struct) error + recallFunc func(context.Context, memory.RecallRequest) (memory.RecallResult, error) + recordFunc func(context.Context, memory.RecordRequest) (memory.RecordResult, error) + updateRecordFunc func(context.Context, memory.UpdateRecordRequest) (memory.RecordResult, error) + deleteRecordFunc func(context.Context, string) (memory.DeleteResult, error) + listRecordsFunc func(context.Context, memory.ListRecordsRequest) (memory.ListRecordsResult, error) + getRecordFunc func(context.Context, string) (memory.Record, error) +} + +func (f *fakeProvider) Capabilities(ctx context.Context) (memory.Capabilities, error) { + if f.capabilitiesFunc != nil { + return f.capabilitiesFunc(ctx) + } + return memory.Capabilities{}, nil +} + +func (f *fakeProvider) Configure(ctx context.Context, cfg *structpb.Struct) error { + if f.configureFunc != nil { + return f.configureFunc(ctx, cfg) + } + return nil +} + +func (f *fakeProvider) Recall(ctx context.Context, req memory.RecallRequest) (memory.RecallResult, error) { + if f.recallFunc != nil { + return f.recallFunc(ctx, req) + } + return memory.RecallResult{}, nil +} + +func (f *fakeProvider) Record(ctx context.Context, req memory.RecordRequest) (memory.RecordResult, error) { + if f.recordFunc != nil { + return f.recordFunc(ctx, req) + } + return memory.RecordResult{}, nil +} + +func (f *fakeProvider) UpdateRecord(ctx context.Context, req memory.UpdateRecordRequest) (memory.RecordResult, error) { + if f.updateRecordFunc != nil { + return f.updateRecordFunc(ctx, req) + } + return memory.RecordResult{}, nil +} + +func (f *fakeProvider) DeleteRecord(ctx context.Context, id string) (memory.DeleteResult, error) { + if f.deleteRecordFunc != nil { + return f.deleteRecordFunc(ctx, id) + } + return memory.DeleteResult{}, nil +} + +func (f *fakeProvider) ListRecords(ctx context.Context, req memory.ListRecordsRequest) (memory.ListRecordsResult, error) { + if f.listRecordsFunc != nil { + return f.listRecordsFunc(ctx, req) + } + return memory.ListRecordsResult{}, nil +} + +func (f *fakeProvider) GetRecord(ctx context.Context, id string) (memory.Record, error) { + if f.getRecordFunc != nil { + return f.getRecordFunc(ctx, id) + } + return memory.Record{}, nil +} + +var _ memory.Provider = (*fakeProvider)(nil) + +// fakeRatifier wraps fakeProvider with both ApproveRecord and RejectRecord, +// satisfying memory.RatificationProvider in full — the "both" half of +// both-or-neither. +type fakeRatifier struct { + fakeProvider + + approveFunc func(context.Context, string) (memory.RecordResult, error) + rejectFunc func(context.Context, string) (memory.DeleteResult, error) +} + +func (f *fakeRatifier) ApproveRecord(ctx context.Context, id string) (memory.RecordResult, error) { + if f.approveFunc != nil { + return f.approveFunc(ctx, id) + } + return memory.RecordResult{}, nil +} + +func (f *fakeRatifier) RejectRecord(ctx context.Context, id string) (memory.DeleteResult, error) { + if f.rejectFunc != nil { + return f.rejectFunc(ctx, id) + } + return memory.DeleteResult{}, nil +} + +var _ memory.RatificationProvider = (*fakeRatifier)(nil) + +// fakePartialRatifier implements ApproveRecord but deliberately NOT +// RejectRecord — the "neither counts as capable" half of both-or-neither. +// This type does NOT satisfy memory.RatificationProvider (RejectRecord is +// missing), so server.go's structural type assertion in NewService must +// treat it as ratification-incapable regardless of what its own +// Capabilities() claims. +type fakePartialRatifier struct { + fakeProvider +} + +func (f *fakePartialRatifier) ApproveRecord(context.Context, string) (memory.RecordResult, error) { + return memory.RecordResult{}, nil +} + +var _ memory.Provider = (*fakePartialRatifier)(nil) + +// fakeRenderer wraps fakeProvider with Render, satisfying memory.Renderer. +type fakeRenderer struct { + fakeProvider + + renderFunc func(context.Context, []byte, string) (*renderv1.RenderTree, error) +} + +func (f *fakeRenderer) Render(ctx context.Context, payload []byte, schemaVersion string) (*renderv1.RenderTree, error) { + if f.renderFunc != nil { + return f.renderFunc(ctx, payload, schemaVersion) + } + return nil, nil +} + +var _ memory.Renderer = (*fakeRenderer)(nil) + +// newTestClient starts svc on an in-memory bufconn listener and returns a +// memoryv1.MemoryServiceClient dialed against it — a real gRPC round trip, +// not a hand-rolled interface fake, so these tests exercise the actual wire +// marshaling server.go's translation code produces. +func newTestClient(t *testing.T, svc *memory.Service) memoryv1.MemoryServiceClient { + t.Helper() + + const bufSize = 1 << 20 + lis := bufconn.Listen(bufSize) + + gs := grpc.NewServer() + svc.Register(gs) + go func() { _ = gs.Serve(lis) }() + t.Cleanup(gs.Stop) + + dialer := func(context.Context, string) (net.Conn, error) { return lis.Dial() } + conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(dialer), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("grpc.NewClient: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + return memoryv1.NewMemoryServiceClient(conn) +} diff --git a/pkg/memory/memory.go b/pkg/memory/memory.go new file mode 100644 index 0000000..a6ba375 --- /dev/null +++ b/pkg/memory/memory.go @@ -0,0 +1,421 @@ +package memory + +import ( + "context" + "fmt" + "time" + + "google.golang.org/protobuf/types/known/structpb" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + "github.com/pluggableharness/agent/pkg/content" + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// Type is this category's fixed record taxonomy +// (docs/specifications/memory/taxonomy.md) — user/feedback/project/ +// reference, never provider-defined. A Record MUST declare exactly one +// Type, immutable after creation. +type Type int + +// The fixed Type taxonomy. TypeUnspecified is never valid on a +// real record — its presence means a caller forgot to set the field, same +// as the generated MemoryType_MEMORY_TYPE_UNSPECIFIED zero value it mirrors. +const ( + TypeUnspecified Type = iota + TypeUser + TypeFeedback + TypeProject + TypeReference +) + +// String returns t's taxonomy name, e.g. "user". +func (t Type) String() string { + switch t { + case TypeUser: + return "user" + case TypeFeedback: + return "feedback" + case TypeProject: + return "project" + case TypeReference: + return "reference" + default: + return "unspecified" + } +} + +// Scope is this category's fixed visibility taxonomy +// (docs/specifications/memory/data-types.md#memoryscope) — +// session/project/global. MUST be set on every Record and is immutable +// after creation, the same as Type. +type Scope int + +// The fixed Scope taxonomy. +const ( + ScopeUnspecified Scope = iota + ScopeSession + ScopeProject + ScopeGlobal +) + +// String returns s's taxonomy name, e.g. "project". +func (s Scope) String() string { + switch s { + case ScopeSession: + return "session" + case ScopeProject: + return "project" + case ScopeGlobal: + return "global" + default: + return "unspecified" + } +} + +// RecordStatus distinguishes a fully-persisted record from one awaiting +// review under the optional ratification pattern +// (docs/specifications/memory/protocol.md#ratification-optional). A +// provider with Capabilities.RatificationSupported == false MUST NEVER +// return RecordStatusPending. +type RecordStatus int + +// The fixed RecordStatus values. +const ( + RecordStatusUnspecified RecordStatus = iota + RecordStatusCanonical + RecordStatusPending +) + +// String returns s's status name, e.g. "pending". +func (s RecordStatus) String() string { + switch s { + case RecordStatusCanonical: + return "canonical" + case RecordStatusPending: + return "pending" + default: + return "unspecified" + } +} + +// Provenance records where a Record came from and who wrote it. It is +// kernel-populated at Record time and immutable thereafter — never +// provider-supplied, never mutated by UpdateRecord +// (docs/specifications/memory/data-types.md#provenance). A Provider +// returning a Record from Recall/ListRecords/GetRecord MAY leave this at +// its zero value; the kernel does not read a plugin-supplied value here as +// authoritative. +type Provenance struct { + // SourceSessionID is the session that produced this record. + SourceSessionID string + // SourceTurnID is the turn (ULID) within SourceSessionID that produced + // this record, when known. Empty when not known (e.g. a + // backfill/import). + SourceTurnID string + // RecordedBy is the producing plugin's declared name, or the reference + // tool path that wrote it (e.g. "memory.remember"). + RecordedBy string +} + +// Record is one persisted unit of memory +// (docs/specifications/memory/data-types.md#recallrequest--memoryrecord). +// Content is text-only in v1, matching the context provider's +// ContextSection content constraint — convert.go collapses the wire +// []ContentBlock to this single string and back. +type Record struct { + // ID is a slug, unique within this provider — kernel-enforced, not + // provider-enforced (docs/specifications/memory/protocol.md#record-updaterecord-deleterecord-the-write-side). + ID string + // Type is this record's fixed taxonomy classification. Immutable after + // creation. + Type Type + // Scope is this record's visibility scope. Immutable after creation, + // like Type. + Scope Scope + // Title is a human-readable title. + Title string + // Content is the record's text content. + Content string + // Tokens is this record's size, computed via CountTokens — never a + // provider-local heuristic. + Tokens int32 + // Status reports whether this record is fully persisted or awaiting + // ratification. + Status RecordStatus + // Links are record IDs this record references, kernel-parsed from + // "[[name]]" syntax in Content at Record/UpdateRecord time — not + // provider-populated. + Links []string + // CreatedAt is when this record was first created. + CreatedAt time.Time + // UpdatedAt is when this record was last modified. + UpdatedAt time.Time + // Provenance records where this record came from. See the Provenance + // doc comment: kernel-populated, not meaningfully provider-supplied. + Provenance Provenance + // RelevanceScore is this record's recall-time relevance, in [0, 1]. + // Set ONLY on Recall/ListRecords results — nil on every other path, + // and MUST NOT be persisted alongside the record itself. A Provider + // that doesn't compute a meaningful relevance figure SHOULD leave this + // nil rather than fabricating a value + // (docs/specifications/memory/data-types.md#relevance_score). + RelevanceScore *float64 +} + +// Capabilities is this provider's capability advertisement, returned by +// GetCapabilities (docs/specifications/memory/data-types.md#memorycapabilities). +// Build one with the helpers in capabilities.go. +type Capabilities struct { + // DefaultTokenBudget is the token budget this provider requests for + // its Recall contributions, absent any override. MUST be set. + DefaultTokenBudget int64 + // SupportedTypes are the MemoryTypes this provider handles. MUST be + // set; MAY be a subset of the full taxonomy. + SupportedTypes []Type + // SupportedScopes are the MemoryScopes this provider handles. MUST be + // set; MAY be a subset (e.g. project-only). + SupportedScopes []Scope + // RatificationSupported reports whether this provider implements the + // ApproveRecord/RejectRecord pattern. NewService overrides whatever + // value a Provider.Capabilities implementation sets here with the + // authoritative, structurally-derived answer — see server.go. + RatificationSupported bool + // SlashCommands are static template-expansion commands this provider + // contributes. MAY be empty. + SlashCommands []*commonv1.PromptExpansionSpec + // ConfigSchema is this provider's agent.hcl config schema. MUST be + // set — build it with pkg/config's Attribute/Schema helpers. + ConfigSchema *configv1.ConfigSchema + // SupportedHookPoints are the hook points this provider declares + // HookSubscriberService.DispatchHook subscriptions for, beyond the + // implicit post_model_response/session_end write triggers. MAY be + // empty. + SupportedHookPoints []commonv1.HookPoint +} + +// RecallRequest is the read-side query, issued at context-assemble time +// (docs/specifications/memory/protocol.md#recall-the-read-side). +type RecallRequest struct { + // SessionID is the requesting session's id. + SessionID string + // TurnID is the current turn within that session, a ULID. + TurnID string + // TokenBudget is the budget this Recall call MUST self-truncate its + // returned records to. + TokenBudget int64 + // ModelTarget is the model this recall is being assembled for. MUST be + // set. + ModelTarget *modelv1.ModelTarget + // FilesTouched are paths touched so far this turn. MAY be empty. + FilesTouched []string + // WorkingDirectory is the session's current working directory. + WorkingDirectory string + // TypeFilter restricts results to these MemoryTypes. Empty means every + // type this provider supports. + TypeFilter []Type + // ScopeFilter restricts results to these MemoryScopes. Empty means + // every scope this provider supports. + ScopeFilter []Scope + // IncludePending reports whether PENDING-status records may be + // included. Defaults to false at the wire boundary — a PENDING record + // MUST NOT surface through ordinary recall unless this is true. + IncludePending bool +} + +// RecallResult carries the records a Recall call judged relevant. +type RecallResult struct { + // Records are the recalled records, in this provider's own relevance + // order. + Records []Record +} + +// RecordRequest creates a new record +// (docs/specifications/memory/protocol.md#record-updaterecord-deleterecord-the-write-side). +type RecordRequest struct { + // Type is this record's fixed taxonomy classification. MUST be set. + Type Type + // Scope is this record's visibility scope. MUST be set. + Scope Scope + // ID is an author-suggested slug. Empty means the provider derives one + // from content; the kernel disambiguates collisions with a numeric + // suffix rather than overwriting or rejecting. + ID string + // Title is a human-readable title. + Title string + // Content is the record's text content. + Content string +} + +// RecordResult is the shared outcome shape for Record, UpdateRecord, and +// ApproveRecord. +type RecordResult struct { + // ID is the final assigned slug. MUST be set. + ID string + // Status reports whether the write is fully persisted or awaiting + // ratification. + Status RecordStatus +} + +// UpdateRecordRequest replaces an existing record's title/content +// wholesale (docs/specifications/memory/data-types.md#the-write-side). +// Deliberately carries no Type or Scope field: both are immutable after +// creation, so this type does not even offer a way to attempt changing +// them — recategorizing a record means DeleteRecord followed by a new +// Record call, never UpdateRecord. +type UpdateRecordRequest struct { + // ID MUST match an existing record, or the call fails with a + // structured *Error{Category: ErrorCategoryNotFound}. + ID string + // Title is the record's new title. Nil leaves the existing title + // unchanged. + Title *string + // Content is the record's new content, replacing the existing content + // wholesale — NOT a patch. A Provider implementation MUST NOT merge + // this with the record's prior content. + Content string +} + +// DeleteResult is the shared outcome shape for DeleteRecord and +// RejectRecord. +type DeleteResult struct { + // Deleted is true if a record was actually removed. + Deleted bool +} + +// ListRecordsRequest is the enumeration/audit query: paginated browsing of +// this provider's records, filterable by type/scope/status +// (docs/specifications/memory/protocol.md#listrecords--getrecord). Unlike +// RecallRequest, there is no IncludePending gate — PENDING records ARE +// listable here. +type ListRecordsRequest struct { + // TypeFilter restricts results to these MemoryTypes. Empty means every + // type this provider supports. + TypeFilter []Type + // ScopeFilter restricts results to these MemoryScopes. Empty means + // every scope this provider supports. + ScopeFilter []Scope + // StatusFilter restricts results to this RecordStatus. Nil means both + // CANONICAL and PENDING records are eligible. + StatusFilter *RecordStatus + // PageSize is the maximum number of records to return in one page. + PageSize int32 + // PageToken is an opaque continuation token from a prior + // ListRecordsResult.NextPageToken. Empty on the first page. + PageToken string +} + +// ListRecordsResult carries one page of matching records. +type ListRecordsResult struct { + // Records are this page's records. + Records []Record + // NextPageToken is the opaque continuation token for the next page. + // Empty when this is the last page. + NextPageToken string +} + +// Provider is the required RPC surface every memory provider MUST +// implement — GetCapabilities, Configure, Recall, Record, UpdateRecord, +// DeleteRecord, ListRecords, and GetRecord +// (docs/specifications/memory/conformance.md's summary matrix). Describe is +// handled by server.go directly from the Identity a plugin author supplies +// to NewService, so it is not part of this interface. +type Provider interface { + // Capabilities reports what this provider supports. Called from the + // GetCapabilities RPC handler. + Capabilities(ctx context.Context) (Capabilities, error) + // Configure decodes this provider's agent.hcl config block, already + // decoded from HCL/cty per the schema this provider advertised in + // Capabilities.ConfigSchema. + Configure(ctx context.Context, cfg *structpb.Struct) error + // Recall returns the records this provider judges relevant to req, + // self-truncated to req.TokenBudget. A candidate set that still + // exceeds TokenBudget after this provider's own truncation MUST fail + // with a *Error{Category: ErrorCategoryBudgetExceeded} — see + // errors.go's BudgetExceeded. + Recall(ctx context.Context, req RecallRequest) (RecallResult, error) + // Record creates a new record. + Record(ctx context.Context, req RecordRequest) (RecordResult, error) + // UpdateRecord replaces an existing record's title/content wholesale — + // NOT a patch. MUST fail with a *Error{Category: ErrorCategoryNotFound} + // if req.ID doesn't match an existing record, rather than silently + // no-op'ing. + UpdateRecord(ctx context.Context, req UpdateRecordRequest) (RecordResult, error) + // DeleteRecord removes an existing record. MUST fail with a + // *Error{Category: ErrorCategoryNotFound} if id doesn't match an + // existing record, rather than silently no-op'ing. + DeleteRecord(ctx context.Context, id string) (DeleteResult, error) + // ListRecords is the enumeration/audit path: paginated browsing, + // filterable by type/scope/status, with PENDING records listable + // without any IncludePending-style gate. + ListRecords(ctx context.Context, req ListRecordsRequest) (ListRecordsResult, error) + // GetRecord fetches exactly one record by id. MUST fail with a + // *Error{Category: ErrorCategoryNotFound} for an unknown id, rather + // than returning an empty result. + GetRecord(ctx context.Context, id string) (Record, error) +} + +// RatificationProvider is the optional ratification pattern +// (docs/specifications/memory/protocol.md#ratification-optional): a +// Provider MAY additionally implement ApproveRecord (transitions PENDING → +// CANONICAL) and RejectRecord (discards a pending draft entirely, NOT a +// soft-delete). Both MUST be implemented together — see server.go's +// NewService doc comment for how this package enforces that structurally +// rather than at runtime. +type RatificationProvider interface { + Provider + + // ApproveRecord transitions a PENDING record to CANONICAL. MUST fail + // with a *Error{Category: ErrorCategoryNotFound} if id doesn't match an + // existing record. + ApproveRecord(ctx context.Context, id string) (RecordResult, error) + // RejectRecord discards a pending draft entirely — NOT a soft-delete. + // MUST fail with a *Error{Category: ErrorCategoryNotFound} if id + // doesn't match an existing record. + RejectRecord(ctx context.Context, id string) (DeleteResult, error) +} + +// Renderer is the optional Render RPC +// (docs/specifications/memory/protocol.md#render): a Provider MAY +// additionally implement this to return its own RenderTree for a payload +// (e.g. a review-inbox view for a PENDING record) in place of the kernel's +// generic fallback. +type Renderer interface { + Provider + + // Render returns a RenderTree for payload, emitted against + // schemaVersion. + Render(ctx context.Context, payload []byte, schemaVersion string) (*renderv1.RenderTree, error) +} + +// CountTokens computes text's token count against modelTarget (a +// ModelTarget.Id, or empty to let the kernel fall back to its documented +// heuristic) via the kernel's CountTokens callback +// (docs/specifications/kernel-callbacks.md#counttokens). Memory providers +// MUST route Record.Tokens computation through this call rather than an +// arbitrary provider-local heuristic +// (docs/specifications/kernel-callbacks.md#why-a-kernel-primitive-not-a-provider-local-heuristic). +func CountTokens(ctx context.Context, cb *plugin.Callback, modelTarget, text string) (int32, error) { + client, err := cb.Client(ctx) + if err != nil { + return 0, fmt.Errorf("memory: count tokens: %w", err) + } + + req := &kernelv1.CountTokensRequest{ + Content: []*contentv1.ContentBlock{content.Text(text)}, + } + if modelTarget != "" { + req.ModelRef = &modelv1.ModelRef{Id: modelTarget} + } + + result, err := client.CountTokens(ctx, req) + if err != nil { + return 0, fmt.Errorf("memory: count tokens: %w", err) + } + return clampInt32(result.GetCount()), nil +} diff --git a/pkg/memory/proto/v1/errors.pb.go b/pkg/memory/proto/v1/errors.pb.go new file mode 100644 index 0000000..4422cde --- /dev/null +++ b/pkg/memory/proto/v1/errors.pb.go @@ -0,0 +1,240 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/memory/v1/errors.proto + +package memoryv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// MemoryErrorCategory is the structured error taxonomy every MemoryError +// classifies into. memory.md §11. +type MemoryErrorCategory int32 + +const ( + // Zero value. Never valid for a real error; its presence on the wire + // means a caller forgot to set the field. + MemoryErrorCategory_MEMORY_ERROR_CATEGORY_UNSPECIFIED MemoryErrorCategory = 0 + // UpdateRecord/DeleteRecord/ApproveRecord/RejectRecord referenced an id + // that doesn't exist. + MemoryErrorCategory_MEMORY_ERROR_CATEGORY_NOT_FOUND MemoryErrorCategory = 1 + // Record specified a MemoryType this provider doesn't support (absent + // from GetCapabilities.supported_types). + MemoryErrorCategory_MEMORY_ERROR_CATEGORY_INVALID_TYPE MemoryErrorCategory = 2 + // ApproveRecord/RejectRecord was called against a provider with + // ratification_supported == false. + MemoryErrorCategory_MEMORY_ERROR_CATEGORY_RATIFICATION_UNSUPPORTED MemoryErrorCategory = 3 + // Recall's candidate records exceed token_budget even after this + // provider's own truncation — the same MUST-self-truncate principle as + // context.md §6. + MemoryErrorCategory_MEMORY_ERROR_CATEGORY_BUDGET_EXCEEDED MemoryErrorCategory = 4 + // This provider's backend storage was unreachable at call time. + MemoryErrorCategory_MEMORY_ERROR_CATEGORY_SOURCE_UNAVAILABLE MemoryErrorCategory = 5 + // Any error not covered by a more specific category above. + MemoryErrorCategory_MEMORY_ERROR_CATEGORY_UNKNOWN MemoryErrorCategory = 6 + // Record specified a MemoryScope this provider doesn't support (absent + // from GetCapabilities.supported_scopes) — the scope-taxonomy mirror of + // MEMORY_ERROR_CATEGORY_INVALID_TYPE above. + MemoryErrorCategory_MEMORY_ERROR_CATEGORY_INVALID_SCOPE MemoryErrorCategory = 7 +) + +// Enum value maps for MemoryErrorCategory. +var ( + MemoryErrorCategory_name = map[int32]string{ + 0: "MEMORY_ERROR_CATEGORY_UNSPECIFIED", + 1: "MEMORY_ERROR_CATEGORY_NOT_FOUND", + 2: "MEMORY_ERROR_CATEGORY_INVALID_TYPE", + 3: "MEMORY_ERROR_CATEGORY_RATIFICATION_UNSUPPORTED", + 4: "MEMORY_ERROR_CATEGORY_BUDGET_EXCEEDED", + 5: "MEMORY_ERROR_CATEGORY_SOURCE_UNAVAILABLE", + 6: "MEMORY_ERROR_CATEGORY_UNKNOWN", + 7: "MEMORY_ERROR_CATEGORY_INVALID_SCOPE", + } + MemoryErrorCategory_value = map[string]int32{ + "MEMORY_ERROR_CATEGORY_UNSPECIFIED": 0, + "MEMORY_ERROR_CATEGORY_NOT_FOUND": 1, + "MEMORY_ERROR_CATEGORY_INVALID_TYPE": 2, + "MEMORY_ERROR_CATEGORY_RATIFICATION_UNSUPPORTED": 3, + "MEMORY_ERROR_CATEGORY_BUDGET_EXCEEDED": 4, + "MEMORY_ERROR_CATEGORY_SOURCE_UNAVAILABLE": 5, + "MEMORY_ERROR_CATEGORY_UNKNOWN": 6, + "MEMORY_ERROR_CATEGORY_INVALID_SCOPE": 7, + } +) + +func (x MemoryErrorCategory) Enum() *MemoryErrorCategory { + p := new(MemoryErrorCategory) + *p = x + return p +} + +func (x MemoryErrorCategory) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (MemoryErrorCategory) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_memory_v1_errors_proto_enumTypes[0].Descriptor() +} + +func (MemoryErrorCategory) Type() protoreflect.EnumType { + return &file_pluggableharness_memory_v1_errors_proto_enumTypes[0] +} + +func (x MemoryErrorCategory) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use MemoryErrorCategory.Descriptor instead. +func (MemoryErrorCategory) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_errors_proto_rawDescGZIP(), []int{0} +} + +// MemoryError is the structured detail carried on a gRPC error status +// crossing the memory provider plugin boundary. memory.md §11. +type MemoryError struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Which category of failure occurred. + Category MemoryErrorCategory `protobuf:"varint,1,opt,name=category,proto3,enum=pluggableharness.memory.v1.MemoryErrorCategory" json:"category,omitempty"` + // Human-readable error detail. + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + // Whether the kernel MAY retry this call as-is. + Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryError) Reset() { + *x = MemoryError{} + mi := &file_pluggableharness_memory_v1_errors_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryError) ProtoMessage() {} + +func (x *MemoryError) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_errors_proto_msgTypes[0] + 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 MemoryError.ProtoReflect.Descriptor instead. +func (*MemoryError) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_errors_proto_rawDescGZIP(), []int{0} +} + +func (x *MemoryError) GetCategory() MemoryErrorCategory { + if x != nil { + return x.Category + } + return MemoryErrorCategory_MEMORY_ERROR_CATEGORY_UNSPECIFIED +} + +func (x *MemoryError) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *MemoryError) GetRetryable() bool { + if x != nil { + return x.Retryable + } + return false +} + +var File_pluggableharness_memory_v1_errors_proto protoreflect.FileDescriptor + +const file_pluggableharness_memory_v1_errors_proto_rawDesc = "" + + "\n" + + "'pluggableharness/memory/v1/errors.proto\x12\x1apluggableharness.memory.v1\"\x92\x01\n" + + "\vMemoryError\x12K\n" + + "\bcategory\x18\x01 \x01(\x0e2/.pluggableharness.memory.v1.MemoryErrorCategoryR\bcategory\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1c\n" + + "\tretryable\x18\x03 \x01(\bR\tretryable*\xe2\x02\n" + + "\x13MemoryErrorCategory\x12%\n" + + "!MEMORY_ERROR_CATEGORY_UNSPECIFIED\x10\x00\x12#\n" + + "\x1fMEMORY_ERROR_CATEGORY_NOT_FOUND\x10\x01\x12&\n" + + "\"MEMORY_ERROR_CATEGORY_INVALID_TYPE\x10\x02\x122\n" + + ".MEMORY_ERROR_CATEGORY_RATIFICATION_UNSUPPORTED\x10\x03\x12)\n" + + "%MEMORY_ERROR_CATEGORY_BUDGET_EXCEEDED\x10\x04\x12,\n" + + "(MEMORY_ERROR_CATEGORY_SOURCE_UNAVAILABLE\x10\x05\x12!\n" + + "\x1dMEMORY_ERROR_CATEGORY_UNKNOWN\x10\x06\x12'\n" + + "#MEMORY_ERROR_CATEGORY_INVALID_SCOPE\x10\aB@Z>github.com/pluggableharness/agent/pkg/memory/proto/v1;memoryv1b\x06proto3" + +var ( + file_pluggableharness_memory_v1_errors_proto_rawDescOnce sync.Once + file_pluggableharness_memory_v1_errors_proto_rawDescData []byte +) + +func file_pluggableharness_memory_v1_errors_proto_rawDescGZIP() []byte { + file_pluggableharness_memory_v1_errors_proto_rawDescOnce.Do(func() { + file_pluggableharness_memory_v1_errors_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_memory_v1_errors_proto_rawDesc), len(file_pluggableharness_memory_v1_errors_proto_rawDesc))) + }) + return file_pluggableharness_memory_v1_errors_proto_rawDescData +} + +var file_pluggableharness_memory_v1_errors_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_pluggableharness_memory_v1_errors_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_pluggableharness_memory_v1_errors_proto_goTypes = []any{ + (MemoryErrorCategory)(0), // 0: pluggableharness.memory.v1.MemoryErrorCategory + (*MemoryError)(nil), // 1: pluggableharness.memory.v1.MemoryError +} +var file_pluggableharness_memory_v1_errors_proto_depIdxs = []int32{ + 0, // 0: pluggableharness.memory.v1.MemoryError.category:type_name -> pluggableharness.memory.v1.MemoryErrorCategory + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_pluggableharness_memory_v1_errors_proto_init() } +func file_pluggableharness_memory_v1_errors_proto_init() { + if File_pluggableharness_memory_v1_errors_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_memory_v1_errors_proto_rawDesc), len(file_pluggableharness_memory_v1_errors_proto_rawDesc)), + NumEnums: 1, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_memory_v1_errors_proto_goTypes, + DependencyIndexes: file_pluggableharness_memory_v1_errors_proto_depIdxs, + EnumInfos: file_pluggableharness_memory_v1_errors_proto_enumTypes, + MessageInfos: file_pluggableharness_memory_v1_errors_proto_msgTypes, + }.Build() + File_pluggableharness_memory_v1_errors_proto = out.File + file_pluggableharness_memory_v1_errors_proto_goTypes = nil + file_pluggableharness_memory_v1_errors_proto_depIdxs = nil +} diff --git a/pkg/memory/proto/v1/memory.pb.go b/pkg/memory/proto/v1/memory.pb.go deleted file mode 100644 index a80f6a4..0000000 --- a/pkg/memory/proto/v1/memory.pb.go +++ /dev/null @@ -1,2472 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: pluggableharness/memory/v1/memory.proto - -// Package pluggableharness.memory.v1 defines the memory provider plugin protocol -// described in specifications/memory.md — plugins that persist knowledge -// across sessions (the write side) and recall it into future ones (the -// read side). A distinct plugin category with its own protocol, not a -// reuse of context.md's Contribute RPC, so record-specific data (type, -// scope, provenance, ratification status) stays first-class through a -// dedicated Recall RPC; the kernel adapts results into ContextSections -// before merging them into the assembled prompt (memory.md §6). - -package memoryv1 - -import ( - v12 "github.com/pluggableharness/agent/pkg/common/proto/v1" - v11 "github.com/pluggableharness/agent/pkg/config/proto/v1" - v14 "github.com/pluggableharness/agent/pkg/content/proto/v1" - v13 "github.com/pluggableharness/agent/pkg/model/proto/v1" - v15 "github.com/pluggableharness/agent/pkg/render/proto/v1" - v1 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - structpb "google.golang.org/protobuf/types/known/structpb" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// MemoryType is the record taxonomy, fixed at the protocol level rather -// than provider-defined (memory.md §4). A record MUST declare exactly one -// MemoryType, and it is immutable after creation — recategorizing means -// DeleteRecord followed by a new Record call, not UpdateRecord. -type MemoryType int32 - -const ( - // Zero value. Never valid for a real record; its presence on the wire - // means a caller forgot to set the field. - MemoryType_MEMORY_TYPE_UNSPECIFIED MemoryType = 0 - // The subject's role, goals, responsibilities, and knowledge. Tailors - // future behavior to who they are and what they already know. - MemoryType_MEMORY_TYPE_USER MemoryType = 1 - // Guidance on how to approach work, captured from both corrections - // ("stop doing X") and confirmations ("yes, keep doing that") — both - // directions matter equally. - MemoryType_MEMORY_TYPE_FEEDBACK MemoryType = 2 - // Ongoing work, goals, decisions, and incidents not otherwise derivable - // from code or git history. Decays faster than the other three types; a - // provider SHOULD weight recency more heavily for this type. - MemoryType_MEMORY_TYPE_PROJECT MemoryType = 3 - // Pointers to where information lives in external systems (an issue - // tracker, a dashboard, a channel) — not the information itself. - MemoryType_MEMORY_TYPE_REFERENCE MemoryType = 4 -) - -// Enum value maps for MemoryType. -var ( - MemoryType_name = map[int32]string{ - 0: "MEMORY_TYPE_UNSPECIFIED", - 1: "MEMORY_TYPE_USER", - 2: "MEMORY_TYPE_FEEDBACK", - 3: "MEMORY_TYPE_PROJECT", - 4: "MEMORY_TYPE_REFERENCE", - } - MemoryType_value = map[string]int32{ - "MEMORY_TYPE_UNSPECIFIED": 0, - "MEMORY_TYPE_USER": 1, - "MEMORY_TYPE_FEEDBACK": 2, - "MEMORY_TYPE_PROJECT": 3, - "MEMORY_TYPE_REFERENCE": 4, - } -) - -func (x MemoryType) Enum() *MemoryType { - p := new(MemoryType) - *p = x - return p -} - -func (x MemoryType) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (MemoryType) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_memory_v1_memory_proto_enumTypes[0].Descriptor() -} - -func (MemoryType) Type() protoreflect.EnumType { - return &file_pluggableharness_memory_v1_memory_proto_enumTypes[0] -} - -func (x MemoryType) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use MemoryType.Descriptor instead. -func (MemoryType) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{0} -} - -// MemoryScope is the visibility taxonomy a record declares, fixed at the -// protocol level (memory.md §4.1). Immutable per record once set, same as -// MemoryType. -type MemoryScope int32 - -const ( - // Zero value. Never valid for a real record; its presence on the wire - // means a caller forgot to set the field. - MemoryScope_MEMORY_SCOPE_UNSPECIFIED MemoryScope = 0 - // Visible only within the session (and its descendants) that wrote it — - // not recalled by unrelated future sessions, though still durably - // logged to the state backend for audit. - MemoryScope_MEMORY_SCOPE_SESSION MemoryScope = 1 - // Scoped to the current working directory/project, recalled by any - // session operating in that project. - MemoryScope_MEMORY_SCOPE_PROJECT MemoryScope = 2 - // Recalled across every project, mirroring a memory system that spans - // all of a subject's work. - MemoryScope_MEMORY_SCOPE_GLOBAL MemoryScope = 3 -) - -// Enum value maps for MemoryScope. -var ( - MemoryScope_name = map[int32]string{ - 0: "MEMORY_SCOPE_UNSPECIFIED", - 1: "MEMORY_SCOPE_SESSION", - 2: "MEMORY_SCOPE_PROJECT", - 3: "MEMORY_SCOPE_GLOBAL", - } - MemoryScope_value = map[string]int32{ - "MEMORY_SCOPE_UNSPECIFIED": 0, - "MEMORY_SCOPE_SESSION": 1, - "MEMORY_SCOPE_PROJECT": 2, - "MEMORY_SCOPE_GLOBAL": 3, - } -) - -func (x MemoryScope) Enum() *MemoryScope { - p := new(MemoryScope) - *p = x - return p -} - -func (x MemoryScope) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (MemoryScope) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_memory_v1_memory_proto_enumTypes[1].Descriptor() -} - -func (MemoryScope) Type() protoreflect.EnumType { - return &file_pluggableharness_memory_v1_memory_proto_enumTypes[1] -} - -func (x MemoryScope) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use MemoryScope.Descriptor instead. -func (MemoryScope) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{1} -} - -// RecordStatus distinguishes a fully-persisted record from one awaiting -// review under the optional ratification pattern (memory.md §8). -type RecordStatus int32 - -const ( - // Zero value. Never valid for a real record; its presence on the wire - // means a caller forgot to set the field. - RecordStatus_RECORD_STATUS_UNSPECIFIED RecordStatus = 0 - // The record is part of what Recall normally surfaces. - RecordStatus_RECORD_STATUS_CANONICAL RecordStatus = 1 - // The record is a drafted-but-not-yet-reviewed write. A provider with - // ratification_supported == false MUST NEVER return this status - // (memory.md §8). - RecordStatus_RECORD_STATUS_PENDING RecordStatus = 2 -) - -// Enum value maps for RecordStatus. -var ( - RecordStatus_name = map[int32]string{ - 0: "RECORD_STATUS_UNSPECIFIED", - 1: "RECORD_STATUS_CANONICAL", - 2: "RECORD_STATUS_PENDING", - } - RecordStatus_value = map[string]int32{ - "RECORD_STATUS_UNSPECIFIED": 0, - "RECORD_STATUS_CANONICAL": 1, - "RECORD_STATUS_PENDING": 2, - } -) - -func (x RecordStatus) Enum() *RecordStatus { - p := new(RecordStatus) - *p = x - return p -} - -func (x RecordStatus) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (RecordStatus) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_memory_v1_memory_proto_enumTypes[2].Descriptor() -} - -func (RecordStatus) Type() protoreflect.EnumType { - return &file_pluggableharness_memory_v1_memory_proto_enumTypes[2] -} - -func (x RecordStatus) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use RecordStatus.Descriptor instead. -func (RecordStatus) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{2} -} - -// MemoryErrorCategory is the structured error taxonomy every MemoryError -// classifies into. memory.md §11. -type MemoryErrorCategory int32 - -const ( - // Zero value. Never valid for a real error; its presence on the wire - // means a caller forgot to set the field. - MemoryErrorCategory_MEMORY_ERROR_CATEGORY_UNSPECIFIED MemoryErrorCategory = 0 - // UpdateRecord/DeleteRecord/ApproveRecord/RejectRecord referenced an id - // that doesn't exist. - MemoryErrorCategory_MEMORY_ERROR_CATEGORY_NOT_FOUND MemoryErrorCategory = 1 - // Record specified a MemoryType this provider doesn't support (absent - // from GetCapabilities.supported_types). - MemoryErrorCategory_MEMORY_ERROR_CATEGORY_INVALID_TYPE MemoryErrorCategory = 2 - // ApproveRecord/RejectRecord was called against a provider with - // ratification_supported == false. - MemoryErrorCategory_MEMORY_ERROR_CATEGORY_RATIFICATION_UNSUPPORTED MemoryErrorCategory = 3 - // Recall's candidate records exceed token_budget even after this - // provider's own truncation — the same MUST-self-truncate principle as - // context.md §6. - MemoryErrorCategory_MEMORY_ERROR_CATEGORY_BUDGET_EXCEEDED MemoryErrorCategory = 4 - // This provider's backend storage was unreachable at call time. - MemoryErrorCategory_MEMORY_ERROR_CATEGORY_SOURCE_UNAVAILABLE MemoryErrorCategory = 5 - // Any error not covered by a more specific category above. - MemoryErrorCategory_MEMORY_ERROR_CATEGORY_UNKNOWN MemoryErrorCategory = 6 - // Record specified a MemoryScope this provider doesn't support (absent - // from GetCapabilities.supported_scopes) — the scope-taxonomy mirror of - // MEMORY_ERROR_CATEGORY_INVALID_TYPE above. - MemoryErrorCategory_MEMORY_ERROR_CATEGORY_INVALID_SCOPE MemoryErrorCategory = 7 -) - -// Enum value maps for MemoryErrorCategory. -var ( - MemoryErrorCategory_name = map[int32]string{ - 0: "MEMORY_ERROR_CATEGORY_UNSPECIFIED", - 1: "MEMORY_ERROR_CATEGORY_NOT_FOUND", - 2: "MEMORY_ERROR_CATEGORY_INVALID_TYPE", - 3: "MEMORY_ERROR_CATEGORY_RATIFICATION_UNSUPPORTED", - 4: "MEMORY_ERROR_CATEGORY_BUDGET_EXCEEDED", - 5: "MEMORY_ERROR_CATEGORY_SOURCE_UNAVAILABLE", - 6: "MEMORY_ERROR_CATEGORY_UNKNOWN", - 7: "MEMORY_ERROR_CATEGORY_INVALID_SCOPE", - } - MemoryErrorCategory_value = map[string]int32{ - "MEMORY_ERROR_CATEGORY_UNSPECIFIED": 0, - "MEMORY_ERROR_CATEGORY_NOT_FOUND": 1, - "MEMORY_ERROR_CATEGORY_INVALID_TYPE": 2, - "MEMORY_ERROR_CATEGORY_RATIFICATION_UNSUPPORTED": 3, - "MEMORY_ERROR_CATEGORY_BUDGET_EXCEEDED": 4, - "MEMORY_ERROR_CATEGORY_SOURCE_UNAVAILABLE": 5, - "MEMORY_ERROR_CATEGORY_UNKNOWN": 6, - "MEMORY_ERROR_CATEGORY_INVALID_SCOPE": 7, - } -) - -func (x MemoryErrorCategory) Enum() *MemoryErrorCategory { - p := new(MemoryErrorCategory) - *p = x - return p -} - -func (x MemoryErrorCategory) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (MemoryErrorCategory) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_memory_v1_memory_proto_enumTypes[3].Descriptor() -} - -func (MemoryErrorCategory) Type() protoreflect.EnumType { - return &file_pluggableharness_memory_v1_memory_proto_enumTypes[3] -} - -func (x MemoryErrorCategory) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use MemoryErrorCategory.Descriptor instead. -func (MemoryErrorCategory) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{3} -} - -// GetCapabilitiesRequest carries no fields; GetCapabilities takes no -// parameters beyond the plugin handshake already having occurred. -type GetCapabilitiesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetCapabilitiesRequest) Reset() { - *x = GetCapabilitiesRequest{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetCapabilitiesRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetCapabilitiesRequest) ProtoMessage() {} - -func (x *GetCapabilitiesRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[0] - 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 GetCapabilitiesRequest.ProtoReflect.Descriptor instead. -func (*GetCapabilitiesRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{0} -} - -// MemoryCapabilities is this provider's capability advertisement, returned -// by GetCapabilities. memory.md §3. -type MemoryCapabilities struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The default token budget this provider requests for its Recall - // contributions, absent any override — same convention as context.md - // §6's reserved token_budget config field. MUST be set. - DefaultTokenBudget int64 `protobuf:"varint,1,opt,name=default_token_budget,json=defaultTokenBudget,proto3" json:"default_token_budget,omitempty"` - // Which MemoryTypes this provider handles. MUST be set; MAY be a subset - // of the full MemoryType enum. - SupportedTypes []MemoryType `protobuf:"varint,2,rep,packed,name=supported_types,json=supportedTypes,proto3,enum=pluggableharness.memory.v1.MemoryType" json:"supported_types,omitempty"` - // Which MemoryScopes this provider handles. MUST be set; MAY be a - // subset of the full MemoryScope enum (e.g. project-only). - SupportedScopes []MemoryScope `protobuf:"varint,3,rep,packed,name=supported_scopes,json=supportedScopes,proto3,enum=pluggableharness.memory.v1.MemoryScope" json:"supported_scopes,omitempty"` - // Whether this provider implements the ApproveRecord/RejectRecord - // ratification pattern (memory.md §8). MUST be set; defaults to false. - RatificationSupported bool `protobuf:"varint,4,opt,name=ratification_supported,json=ratificationSupported,proto3" json:"ratification_supported,omitempty"` - // Slash commands this provider contributes, per frontend.md §5. MAY be - // empty — the reference tools (memory.md §9.2) already cover the common - // remember/forget/search cases via the ordinary tool-provider path. - SlashCommands []*v1.SlashCommandSpec `protobuf:"bytes,5,rep,name=slash_commands,json=slashCommands,proto3" json:"slash_commands,omitempty"` - // This provider's agent.hcl config schema, per configuration.md §4. - ConfigSchema *v11.ConfigSchema `protobuf:"bytes,6,opt,name=config_schema,json=configSchema,proto3" json:"config_schema,omitempty"` - // Which hook points (agent-loop/hook-dispatch.md) this provider declares - // HookSubscriberService.DispatchHook subscriptions for. Note: this - // category's implicit post_model_response (observe, every turn) and - // session_end (fires once) write triggers (memory.md §Write triggers) - // now ride the hook.v1 HookSubscriberService.DispatchHook surface — see - // protocol.md's Write triggers section for the payload shapes. The - // HookPoint enum itself lives in common.v1, not hook.v1 — hook.v1 - // imports this package's model/tool/plan dependencies, so a category - // capability message importing hook.v1 directly would cycle back - // through it; common.v1 is the shared leaf package instead. - SupportedHookPoints []v12.HookPoint `protobuf:"varint,7,rep,packed,name=supported_hook_points,json=supportedHookPoints,proto3,enum=pluggableharness.common.v1.HookPoint" json:"supported_hook_points,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *MemoryCapabilities) Reset() { - *x = MemoryCapabilities{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *MemoryCapabilities) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*MemoryCapabilities) ProtoMessage() {} - -func (x *MemoryCapabilities) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[1] - 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 MemoryCapabilities.ProtoReflect.Descriptor instead. -func (*MemoryCapabilities) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{1} -} - -func (x *MemoryCapabilities) GetDefaultTokenBudget() int64 { - if x != nil { - return x.DefaultTokenBudget - } - return 0 -} - -func (x *MemoryCapabilities) GetSupportedTypes() []MemoryType { - if x != nil { - return x.SupportedTypes - } - return nil -} - -func (x *MemoryCapabilities) GetSupportedScopes() []MemoryScope { - if x != nil { - return x.SupportedScopes - } - return nil -} - -func (x *MemoryCapabilities) GetRatificationSupported() bool { - if x != nil { - return x.RatificationSupported - } - return false -} - -func (x *MemoryCapabilities) GetSlashCommands() []*v1.SlashCommandSpec { - if x != nil { - return x.SlashCommands - } - return nil -} - -func (x *MemoryCapabilities) GetConfigSchema() *v11.ConfigSchema { - if x != nil { - return x.ConfigSchema - } - return nil -} - -func (x *MemoryCapabilities) GetSupportedHookPoints() []v12.HookPoint { - if x != nil { - return x.SupportedHookPoints - } - return nil -} - -// GetCapabilitiesResponse wraps this provider's capability advertisement. -type GetCapabilitiesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // This provider's capabilities. - Capabilities *MemoryCapabilities `protobuf:"bytes,1,opt,name=capabilities,proto3" json:"capabilities,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetCapabilitiesResponse) Reset() { - *x = GetCapabilitiesResponse{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetCapabilitiesResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetCapabilitiesResponse) ProtoMessage() {} - -func (x *GetCapabilitiesResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[2] - 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 GetCapabilitiesResponse.ProtoReflect.Descriptor instead. -func (*GetCapabilitiesResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{2} -} - -func (x *GetCapabilitiesResponse) GetCapabilities() *MemoryCapabilities { - if x != nil { - return x.Capabilities - } - return nil -} - -// ConfigureRequest wraps this provider's already-decoded agent.hcl config -// block. memory.md §5. -type ConfigureRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The decoded config, per the schema this provider advertised in - // MemoryCapabilities.config_schema (configuration.md §4). Already - // decoded from HCL/cty by the kernel — this is a Struct because the - // shape is genuinely provider-defined at the proto level (see - // .claude/rules/proto.md's Struct carve-out), not because the value - // itself is untyped. - Config *structpb.Struct `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ConfigureRequest) Reset() { - *x = ConfigureRequest{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ConfigureRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConfigureRequest) ProtoMessage() {} - -func (x *ConfigureRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[3] - 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 ConfigureRequest.ProtoReflect.Descriptor instead. -func (*ConfigureRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{3} -} - -func (x *ConfigureRequest) GetConfig() *structpb.Struct { - if x != nil { - return x.Config - } - return nil -} - -// ConfigureResponse is empty on success. Errors surface as a gRPC status -// carrying a MemoryError in its structured detail, per grpc.md — not an -// in-band field on this message. -type ConfigureResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ConfigureResponse) Reset() { - *x = ConfigureResponse{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ConfigureResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConfigureResponse) ProtoMessage() {} - -func (x *ConfigureResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[4] - 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 ConfigureResponse.ProtoReflect.Descriptor instead. -func (*ConfigureResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{4} -} - -// RecallRequest is the read-side query, issued at context-assemble time. -// memory.md §6. -type RecallRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The requesting session's id. - SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - // The current turn within that session. A ULID, standardized across the - // whole protocol (matches plan.v1's turn_id field and context.v1's - // ContextRequest.turn_id, same treatment). Same field number as the - // retired int64 turn-number predecessor field; the rename is a - // sanctioned pre-release wire change, not a v2 bump. - TurnId string `protobuf:"bytes,2,opt,name=turn_id,json=turnId,proto3" json:"turn_id,omitempty"` - // The token budget this Recall call MUST self-truncate its returned - // records to. Competes for the same budget pool as context providers, - // resolved the same way context.md §6 resolves a context provider's cap. - TokenBudget int64 `protobuf:"varint,3,opt,name=token_budget,json=tokenBudget,proto3" json:"token_budget,omitempty"` - // The model this recall is being assembled for, mirroring context.md - // §4's ContextRequest field. Lets a provider pass a precise model - // reference into the CountTokens kernel callback (kernel-callbacks.md - // §2) — added per kernel-callbacks.md §6's correction. MUST be set. - ModelTarget *v13.ModelTarget `protobuf:"bytes,4,opt,name=model_target,json=modelTarget,proto3" json:"model_target,omitempty"` - // Paths of files touched so far this turn, mirroring context.md §4's - // field of the same name. MAY be empty. - FilesTouched []string `protobuf:"bytes,5,rep,name=files_touched,json=filesTouched,proto3" json:"files_touched,omitempty"` - // The session's current working directory. - WorkingDirectory string `protobuf:"bytes,6,opt,name=working_directory,json=workingDirectory,proto3" json:"working_directory,omitempty"` - // Restricts results to these MemoryTypes. MAY be empty, meaning all - // types this provider supports. - TypeFilter []MemoryType `protobuf:"varint,7,rep,packed,name=type_filter,json=typeFilter,proto3,enum=pluggableharness.memory.v1.MemoryType" json:"type_filter,omitempty"` - // Restricts results to these MemoryScopes. MAY be empty, meaning all - // scopes this provider supports. - ScopeFilter []MemoryScope `protobuf:"varint,8,rep,packed,name=scope_filter,json=scopeFilter,proto3,enum=pluggableharness.memory.v1.MemoryScope" json:"scope_filter,omitempty"` - // Whether PENDING-status records may be included in the response. MUST - // default to false — a PENDING record MUST NOT surface through ordinary - // recall unless this is true (memory.md §6, §8). - IncludePending bool `protobuf:"varint,9,opt,name=include_pending,json=includePending,proto3" json:"include_pending,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RecallRequest) Reset() { - *x = RecallRequest{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RecallRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RecallRequest) ProtoMessage() {} - -func (x *RecallRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_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 RecallRequest.ProtoReflect.Descriptor instead. -func (*RecallRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{5} -} - -func (x *RecallRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -func (x *RecallRequest) GetTurnId() string { - if x != nil { - return x.TurnId - } - return "" -} - -func (x *RecallRequest) GetTokenBudget() int64 { - if x != nil { - return x.TokenBudget - } - return 0 -} - -func (x *RecallRequest) GetModelTarget() *v13.ModelTarget { - if x != nil { - return x.ModelTarget - } - return nil -} - -func (x *RecallRequest) GetFilesTouched() []string { - if x != nil { - return x.FilesTouched - } - return nil -} - -func (x *RecallRequest) GetWorkingDirectory() string { - if x != nil { - return x.WorkingDirectory - } - return "" -} - -func (x *RecallRequest) GetTypeFilter() []MemoryType { - if x != nil { - return x.TypeFilter - } - return nil -} - -func (x *RecallRequest) GetScopeFilter() []MemoryScope { - if x != nil { - return x.ScopeFilter - } - return nil -} - -func (x *RecallRequest) GetIncludePending() bool { - if x != nil { - return x.IncludePending - } - return false -} - -// RecallResponse carries the records this provider judges relevant to the -// requesting RecallRequest. -type RecallResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The recalled records, in this provider's own relevance order. - Records []*MemoryRecord `protobuf:"bytes,1,rep,name=records,proto3" json:"records,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RecallResponse) Reset() { - *x = RecallResponse{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RecallResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RecallResponse) ProtoMessage() {} - -func (x *RecallResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_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 RecallResponse.ProtoReflect.Descriptor instead. -func (*RecallResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{6} -} - -func (x *RecallResponse) GetRecords() []*MemoryRecord { - if x != nil { - return x.Records - } - return nil -} - -// MemoryRecord is one persisted unit of memory. memory.md §6. -type MemoryRecord struct { - state protoimpl.MessageState `protogen:"open.v1"` - // A slug, unique within this provider. Kernel-enforced uniqueness per - // memory.md §7. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - // This record's fixed taxonomy classification. Immutable after - // creation. - Type MemoryType `protobuf:"varint,2,opt,name=type,proto3,enum=pluggableharness.memory.v1.MemoryType" json:"type,omitempty"` - // This record's visibility scope. MUST be set; immutable after - // creation, like `type`. - Scope MemoryScope `protobuf:"varint,3,opt,name=scope,proto3,enum=pluggableharness.memory.v1.MemoryScope" json:"scope,omitempty"` - // Human-readable title. - Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` - // The record's content. Text-only in v1, same constraint as context.md - // §4. - Content []*v14.ContentBlock `protobuf:"bytes,5,rep,name=content,proto3" json:"content,omitempty"` - // This record's size, computed via the kernel's CountTokens callback - // (kernel-callbacks.md §2), never a provider-local heuristic. - Tokens int64 `protobuf:"varint,6,opt,name=tokens,proto3" json:"tokens,omitempty"` - // Whether this record is fully persisted or awaiting ratification. - Status RecordStatus `protobuf:"varint,7,opt,name=status,proto3,enum=pluggableharness.memory.v1.RecordStatus" json:"status,omitempty"` - // Record ids this record references. MUST be set — kernel-parsed from - // "[[name]]" syntax in `content` at Record/UpdateRecord time - // (memory.md §7.1), not provider-populated. - Links []string `protobuf:"bytes,8,rep,name=links,proto3" json:"links,omitempty"` - // When this record was first created. - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - // When this record was last modified. - UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` - // Where this record came from and who wrote it. Kernel-populated at - // Record time, immutable thereafter — never provider-supplied or - // provider-mutable. memory.md's README already frames provenance as - // first-class; this is where the record shape finally carries it. - Provenance *Provenance `protobuf:"bytes,11,opt,name=provenance,proto3" json:"provenance,omitempty"` - // This record's recall-time relevance, in [0, 1]. Set only on - // Recall/ListRecords responses — never persisted, never present on a - // Record/UpdateRecord request or response. Lets the kernel merge - // multiple memory providers' results under one shared budget - // (data-types.md#recallrequest--memoryrecord) using a comparable score - // rather than provider-opaque ordering alone. A provider setting this - // MUST normalize it to [0, 1]; scores from different providers are only - // meaningfully comparable if each normalizes to the same range. - RelevanceScore *float64 `protobuf:"fixed64,12,opt,name=relevance_score,json=relevanceScore,proto3,oneof" json:"relevance_score,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *MemoryRecord) Reset() { - *x = MemoryRecord{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *MemoryRecord) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*MemoryRecord) ProtoMessage() {} - -func (x *MemoryRecord) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[7] - 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 MemoryRecord.ProtoReflect.Descriptor instead. -func (*MemoryRecord) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{7} -} - -func (x *MemoryRecord) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *MemoryRecord) GetType() MemoryType { - if x != nil { - return x.Type - } - return MemoryType_MEMORY_TYPE_UNSPECIFIED -} - -func (x *MemoryRecord) GetScope() MemoryScope { - if x != nil { - return x.Scope - } - return MemoryScope_MEMORY_SCOPE_UNSPECIFIED -} - -func (x *MemoryRecord) GetTitle() string { - if x != nil { - return x.Title - } - return "" -} - -func (x *MemoryRecord) GetContent() []*v14.ContentBlock { - if x != nil { - return x.Content - } - return nil -} - -func (x *MemoryRecord) GetTokens() int64 { - if x != nil { - return x.Tokens - } - return 0 -} - -func (x *MemoryRecord) GetStatus() RecordStatus { - if x != nil { - return x.Status - } - return RecordStatus_RECORD_STATUS_UNSPECIFIED -} - -func (x *MemoryRecord) GetLinks() []string { - if x != nil { - return x.Links - } - return nil -} - -func (x *MemoryRecord) GetCreatedAt() *timestamppb.Timestamp { - if x != nil { - return x.CreatedAt - } - return nil -} - -func (x *MemoryRecord) GetUpdatedAt() *timestamppb.Timestamp { - if x != nil { - return x.UpdatedAt - } - return nil -} - -func (x *MemoryRecord) GetProvenance() *Provenance { - if x != nil { - return x.Provenance - } - return nil -} - -func (x *MemoryRecord) GetRelevanceScore() float64 { - if x != nil && x.RelevanceScore != nil { - return *x.RelevanceScore - } - return 0 -} - -// Provenance records where a MemoryRecord came from and who wrote it. -// Kernel-populated at Record time; immutable afterward, like the rest of a -// record's write-time metadata. -type Provenance struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The session id that produced this record. - SourceSessionId string `protobuf:"bytes,1,opt,name=source_session_id,json=sourceSessionId,proto3" json:"source_session_id,omitempty"` - // The turn id (ULID, see ContextRequest.turn_id) within source_session_id - // that produced this record, when known. Absent for a record written - // outside normal turn flow (e.g. a backfill/import). - SourceTurnId *string `protobuf:"bytes,2,opt,name=source_turn_id,json=sourceTurnId,proto3,oneof" json:"source_turn_id,omitempty"` - // The producing plugin's declared name, or the reference tool path that - // wrote it (e.g. "memory.remember"). Kernel-populated from the calling - // context, not provider-supplied. - RecordedBy string `protobuf:"bytes,3,opt,name=recorded_by,json=recordedBy,proto3" json:"recorded_by,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Provenance) Reset() { - *x = Provenance{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Provenance) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Provenance) ProtoMessage() {} - -func (x *Provenance) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[8] - 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 Provenance.ProtoReflect.Descriptor instead. -func (*Provenance) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{8} -} - -func (x *Provenance) GetSourceSessionId() string { - if x != nil { - return x.SourceSessionId - } - return "" -} - -func (x *Provenance) GetSourceTurnId() string { - if x != nil && x.SourceTurnId != nil { - return *x.SourceTurnId - } - return "" -} - -func (x *Provenance) GetRecordedBy() string { - if x != nil { - return x.RecordedBy - } - return "" -} - -// RecordRequest creates a new record. memory.md §7. -type RecordRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // This record's fixed taxonomy classification. MUST be set. - Type MemoryType `protobuf:"varint,1,opt,name=type,proto3,enum=pluggableharness.memory.v1.MemoryType" json:"type,omitempty"` - // This record's visibility scope. MUST be set. - Scope MemoryScope `protobuf:"varint,2,opt,name=scope,proto3,enum=pluggableharness.memory.v1.MemoryScope" json:"scope,omitempty"` - // An author-suggested slug. MAY be omitted, in which case the provider - // derives one from content; the kernel disambiguates collisions with a - // numeric suffix (memory.md §7) rather than overwriting or rejecting. - Id *string `protobuf:"bytes,3,opt,name=id,proto3,oneof" json:"id,omitempty"` - // Human-readable title. - Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` - // The record's content. - Content []*v14.ContentBlock `protobuf:"bytes,5,rep,name=content,proto3" json:"content,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RecordRequest) Reset() { - *x = RecordRequest{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RecordRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RecordRequest) ProtoMessage() {} - -func (x *RecordRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[9] - 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 RecordRequest.ProtoReflect.Descriptor instead. -func (*RecordRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{9} -} - -func (x *RecordRequest) GetType() MemoryType { - if x != nil { - return x.Type - } - return MemoryType_MEMORY_TYPE_UNSPECIFIED -} - -func (x *RecordRequest) GetScope() MemoryScope { - if x != nil { - return x.Scope - } - return MemoryScope_MEMORY_SCOPE_UNSPECIFIED -} - -func (x *RecordRequest) GetId() string { - if x != nil && x.Id != nil { - return *x.Id - } - return "" -} - -func (x *RecordRequest) GetTitle() string { - if x != nil { - return x.Title - } - return "" -} - -func (x *RecordRequest) GetContent() []*v14.ContentBlock { - if x != nil { - return x.Content - } - return nil -} - -// RecordResult is the shared outcome shape for Record (via -// RecordResponse), UpdateRecord (via UpdateRecordResponse), and -// ApproveRecord (via ApproveRecordResponse) — a reusable domain type, not -// itself an RPC response type for more than one RPC. -type RecordResult struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The final assigned slug. MUST be set. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - // Whether the write is fully persisted or awaiting ratification. - Status RecordStatus `protobuf:"varint,2,opt,name=status,proto3,enum=pluggableharness.memory.v1.RecordStatus" json:"status,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RecordResult) Reset() { - *x = RecordResult{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RecordResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RecordResult) ProtoMessage() {} - -func (x *RecordResult) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[10] - 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 RecordResult.ProtoReflect.Descriptor instead. -func (*RecordResult) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{10} -} - -func (x *RecordResult) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *RecordResult) GetStatus() RecordStatus { - if x != nil { - return x.Status - } - return RecordStatus_RECORD_STATUS_UNSPECIFIED -} - -// RecordResponse wraps Record's outcome. -type RecordResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The newly created record's outcome. - Result *RecordResult `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RecordResponse) Reset() { - *x = RecordResponse{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RecordResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RecordResponse) ProtoMessage() {} - -func (x *RecordResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[11] - 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 RecordResponse.ProtoReflect.Descriptor instead. -func (*RecordResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{11} -} - -func (x *RecordResponse) GetResult() *RecordResult { - if x != nil { - return x.Result - } - return nil -} - -// UpdateRecordRequest replaces an existing record's title/content -// wholesale. memory.md §7. -type UpdateRecordRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The existing record's id. MUST match an existing record, or the call - // fails with a structured MemoryError. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - // The record's new title. Unset leaves the existing title unchanged. - Title *string `protobuf:"bytes,2,opt,name=title,proto3,oneof" json:"title,omitempty"` - // The record's new content, replacing the existing content wholesale — - // not a patch. MUST be set. - Content []*v14.ContentBlock `protobuf:"bytes,3,rep,name=content,proto3" json:"content,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *UpdateRecordRequest) Reset() { - *x = UpdateRecordRequest{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *UpdateRecordRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*UpdateRecordRequest) ProtoMessage() {} - -func (x *UpdateRecordRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[12] - 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 UpdateRecordRequest.ProtoReflect.Descriptor instead. -func (*UpdateRecordRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{12} -} - -func (x *UpdateRecordRequest) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *UpdateRecordRequest) GetTitle() string { - if x != nil && x.Title != nil { - return *x.Title - } - return "" -} - -func (x *UpdateRecordRequest) GetContent() []*v14.ContentBlock { - if x != nil { - return x.Content - } - return nil -} - -// UpdateRecordResponse wraps UpdateRecord's outcome, which is the same -// shape as Record's (memory.md §7) but kept as its own per-RPC response -// type — RecordResult itself stays a reusable domain type, not an RPC -// response type shared across RPCs. -type UpdateRecordResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The updated record's outcome. - Result *RecordResult `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *UpdateRecordResponse) Reset() { - *x = UpdateRecordResponse{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *UpdateRecordResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*UpdateRecordResponse) ProtoMessage() {} - -func (x *UpdateRecordResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[13] - 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 UpdateRecordResponse.ProtoReflect.Descriptor instead. -func (*UpdateRecordResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{13} -} - -func (x *UpdateRecordResponse) GetResult() *RecordResult { - if x != nil { - return x.Result - } - return nil -} - -// DeleteRecordRequest identifies the record to remove. memory.md §7. -type DeleteRecordRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The existing record's id. MUST match an existing record, or the call - // fails with a structured MemoryError. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteRecordRequest) Reset() { - *x = DeleteRecordRequest{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteRecordRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteRecordRequest) ProtoMessage() {} - -func (x *DeleteRecordRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[14] - 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 DeleteRecordRequest.ProtoReflect.Descriptor instead. -func (*DeleteRecordRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{14} -} - -func (x *DeleteRecordRequest) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -// DeleteResult is the shared outcome shape for DeleteRecord (via -// DeleteRecordResponse) and RejectRecord (via RejectRecordResponse) — a -// reusable domain type, not itself an RPC response type for more than one -// RPC. -type DeleteResult struct { - state protoimpl.MessageState `protogen:"open.v1"` - // True if a record was actually removed. - Deleted bool `protobuf:"varint,1,opt,name=deleted,proto3" json:"deleted,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteResult) Reset() { - *x = DeleteResult{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteResult) ProtoMessage() {} - -func (x *DeleteResult) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_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 DeleteResult.ProtoReflect.Descriptor instead. -func (*DeleteResult) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{15} -} - -func (x *DeleteResult) GetDeleted() bool { - if x != nil { - return x.Deleted - } - return false -} - -// DeleteRecordResponse wraps DeleteRecord's outcome. -type DeleteRecordResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Whether the record was actually removed. - Result *DeleteResult `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteRecordResponse) Reset() { - *x = DeleteRecordResponse{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteRecordResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteRecordResponse) ProtoMessage() {} - -func (x *DeleteRecordResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_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 DeleteRecordResponse.ProtoReflect.Descriptor instead. -func (*DeleteRecordResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{16} -} - -func (x *DeleteRecordResponse) GetResult() *DeleteResult { - if x != nil { - return x.Result - } - return nil -} - -// ApproveRecordRequest identifies the pending record to transition to -// CANONICAL. memory.md §8. -type ApproveRecordRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The pending record's id. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ApproveRecordRequest) Reset() { - *x = ApproveRecordRequest{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[17] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ApproveRecordRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ApproveRecordRequest) ProtoMessage() {} - -func (x *ApproveRecordRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[17] - 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 ApproveRecordRequest.ProtoReflect.Descriptor instead. -func (*ApproveRecordRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{17} -} - -func (x *ApproveRecordRequest) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -// ApproveRecordResponse wraps ApproveRecord's outcome, which is the same -// shape as Record's (memory.md §8) but kept as its own per-RPC response -// type — RecordResult itself stays a reusable domain type, not an RPC -// response type shared across RPCs. -type ApproveRecordResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The now-canonical record's outcome. - Result *RecordResult `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ApproveRecordResponse) Reset() { - *x = ApproveRecordResponse{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[18] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ApproveRecordResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ApproveRecordResponse) ProtoMessage() {} - -func (x *ApproveRecordResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[18] - 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 ApproveRecordResponse.ProtoReflect.Descriptor instead. -func (*ApproveRecordResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{18} -} - -func (x *ApproveRecordResponse) GetResult() *RecordResult { - if x != nil { - return x.Result - } - return nil -} - -// RejectRecordRequest identifies the pending record to discard entirely. -// memory.md §8. -type RejectRecordRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The pending record's id. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RejectRecordRequest) Reset() { - *x = RejectRecordRequest{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[19] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RejectRecordRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RejectRecordRequest) ProtoMessage() {} - -func (x *RejectRecordRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[19] - 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 RejectRecordRequest.ProtoReflect.Descriptor instead. -func (*RejectRecordRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{19} -} - -func (x *RejectRecordRequest) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -// RejectRecordResponse wraps RejectRecord's outcome, which is the same -// shape as DeleteRecord's (memory.md §8) but kept as its own per-RPC -// response type — DeleteResult itself stays a reusable domain type, not an -// RPC response type shared across RPCs. -type RejectRecordResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Whether the pending draft was actually discarded. - Result *DeleteResult `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RejectRecordResponse) Reset() { - *x = RejectRecordResponse{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[20] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RejectRecordResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RejectRecordResponse) ProtoMessage() {} - -func (x *RejectRecordResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[20] - 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 RejectRecordResponse.ProtoReflect.Descriptor instead. -func (*RejectRecordResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{20} -} - -func (x *RejectRecordResponse) GetResult() *DeleteResult { - if x != nil { - return x.Result - } - return nil -} - -// MemoryError is the structured detail carried on a gRPC error status -// crossing the memory provider plugin boundary. memory.md §11. -type MemoryError struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Which category of failure occurred. - Category MemoryErrorCategory `protobuf:"varint,1,opt,name=category,proto3,enum=pluggableharness.memory.v1.MemoryErrorCategory" json:"category,omitempty"` - // Human-readable error detail. - Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` - // Whether the kernel MAY retry this call as-is. - Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *MemoryError) Reset() { - *x = MemoryError{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[21] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *MemoryError) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*MemoryError) ProtoMessage() {} - -func (x *MemoryError) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[21] - 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 MemoryError.ProtoReflect.Descriptor instead. -func (*MemoryError) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{21} -} - -func (x *MemoryError) GetCategory() MemoryErrorCategory { - if x != nil { - return x.Category - } - return MemoryErrorCategory_MEMORY_ERROR_CATEGORY_UNSPECIFIED -} - -func (x *MemoryError) GetMessage() string { - if x != nil { - return x.Message - } - return "" -} - -func (x *MemoryError) GetRetryable() bool { - if x != nil { - return x.Retryable - } - return false -} - -// RenderRequest carries the opaque payload to render. memory.md §10. -type RenderRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The opaque emitted payload to render — see grpc.md's Emit->Render-> - // Paint carve-out; this is the one field in this file that is - // deliberately untyped bytes rather than a concrete message. - Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` - // The schema version this payload was emitted against, so a Render - // implementation can detect drift between the version it was built - // against and the version live in a running session. See - // ../frontend/render-tree.md#schema-versioning for the canonical - // definition of this field's semantics (owned by the frontend/widget - // workstream; this field just carries the same value). - SchemaVersion string `protobuf:"bytes,2,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RenderRequest) Reset() { - *x = RenderRequest{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[22] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RenderRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RenderRequest) ProtoMessage() {} - -func (x *RenderRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[22] - 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 RenderRequest.ProtoReflect.Descriptor instead. -func (*RenderRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{22} -} - -func (x *RenderRequest) GetPayload() []byte { - if x != nil { - return x.Payload - } - return nil -} - -func (x *RenderRequest) GetSchemaVersion() string { - if x != nil { - return x.SchemaVersion - } - return "" -} - -// RenderResponse wraps this provider's rendered output. memory.md §10. -type RenderResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The rendered tree, per frontend.md §1. - Tree *v15.RenderTree `protobuf:"bytes,1,opt,name=tree,proto3" json:"tree,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RenderResponse) Reset() { - *x = RenderResponse{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[23] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RenderResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RenderResponse) ProtoMessage() {} - -func (x *RenderResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[23] - 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 RenderResponse.ProtoReflect.Descriptor instead. -func (*RenderResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{23} -} - -func (x *RenderResponse) GetTree() *v15.RenderTree { - if x != nil { - return x.Tree - } - return nil -} - -// ListRecordsRequest is the enumeration/audit query: paginated browsing of -// this provider's records, filterable by type/scope/status. Unlike -// RecallRequest, there is no include_pending gate — this is the -// review-inbox path (e.g. a ratification review UI or generic record -// browsing), where PENDING records ARE listable, not the budget-constrained -// per-turn recall path. memory.md §7. -type ListRecordsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Restricts results to these MemoryTypes. MAY be empty, meaning all - // types this provider supports. - TypeFilter []MemoryType `protobuf:"varint,1,rep,packed,name=type_filter,json=typeFilter,proto3,enum=pluggableharness.memory.v1.MemoryType" json:"type_filter,omitempty"` - // Restricts results to these MemoryScopes. MAY be empty, meaning all - // scopes this provider supports. - ScopeFilter []MemoryScope `protobuf:"varint,2,rep,packed,name=scope_filter,json=scopeFilter,proto3,enum=pluggableharness.memory.v1.MemoryScope" json:"scope_filter,omitempty"` - // Restricts results to this RecordStatus. Unset means both CANONICAL and - // PENDING records are eligible — this is the one path where a PENDING - // record is listable without any include_pending-style gate. - StatusFilter *RecordStatus `protobuf:"varint,3,opt,name=status_filter,json=statusFilter,proto3,enum=pluggableharness.memory.v1.RecordStatus,oneof" json:"status_filter,omitempty"` - // Maximum number of records to return in one page. - PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` - // Opaque continuation token from a prior ListRecordsResponse. - // next_page_token. Empty on the first page. - PageToken string `protobuf:"bytes,5,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListRecordsRequest) Reset() { - *x = ListRecordsRequest{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[24] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListRecordsRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListRecordsRequest) ProtoMessage() {} - -func (x *ListRecordsRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[24] - 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 ListRecordsRequest.ProtoReflect.Descriptor instead. -func (*ListRecordsRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{24} -} - -func (x *ListRecordsRequest) GetTypeFilter() []MemoryType { - if x != nil { - return x.TypeFilter - } - return nil -} - -func (x *ListRecordsRequest) GetScopeFilter() []MemoryScope { - if x != nil { - return x.ScopeFilter - } - return nil -} - -func (x *ListRecordsRequest) GetStatusFilter() RecordStatus { - if x != nil && x.StatusFilter != nil { - return *x.StatusFilter - } - return RecordStatus_RECORD_STATUS_UNSPECIFIED -} - -func (x *ListRecordsRequest) GetPageSize() int32 { - if x != nil { - return x.PageSize - } - return 0 -} - -func (x *ListRecordsRequest) GetPageToken() string { - if x != nil { - return x.PageToken - } - return "" -} - -// ListRecordsResponse carries one page of matching records. -type ListRecordsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // This page's records. - Records []*MemoryRecord `protobuf:"bytes,1,rep,name=records,proto3" json:"records,omitempty"` - // Opaque continuation token for the next page. Empty when this is the - // last page. - NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListRecordsResponse) Reset() { - *x = ListRecordsResponse{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[25] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListRecordsResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListRecordsResponse) ProtoMessage() {} - -func (x *ListRecordsResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[25] - 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 ListRecordsResponse.ProtoReflect.Descriptor instead. -func (*ListRecordsResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{25} -} - -func (x *ListRecordsResponse) GetRecords() []*MemoryRecord { - if x != nil { - return x.Records - } - return nil -} - -func (x *ListRecordsResponse) GetNextPageToken() string { - if x != nil { - return x.NextPageToken - } - return "" -} - -// GetRecordRequest identifies exactly one record to fetch by id. -type GetRecordRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The record's id. MUST match an existing record, or the call fails with - // a structured MemoryError (category NOT_FOUND). - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetRecordRequest) Reset() { - *x = GetRecordRequest{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[26] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetRecordRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetRecordRequest) ProtoMessage() {} - -func (x *GetRecordRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[26] - 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 GetRecordRequest.ProtoReflect.Descriptor instead. -func (*GetRecordRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{26} -} - -func (x *GetRecordRequest) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -// GetRecordResponse wraps the fetched record. -type GetRecordResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The fetched record. - Record *MemoryRecord `protobuf:"bytes,1,opt,name=record,proto3" json:"record,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetRecordResponse) Reset() { - *x = GetRecordResponse{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[27] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetRecordResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetRecordResponse) ProtoMessage() {} - -func (x *GetRecordResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[27] - 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 GetRecordResponse.ProtoReflect.Descriptor instead. -func (*GetRecordResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{27} -} - -func (x *GetRecordResponse) GetRecord() *MemoryRecord { - if x != nil { - return x.Record - } - return nil -} - -// DescribeRequest carries no fields — Describe takes no request-scoped -// parameters. -type DescribeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DescribeRequest) Reset() { - *x = DescribeRequest{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[28] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DescribeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DescribeRequest) ProtoMessage() {} - -func (x *DescribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[28] - 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 DescribeRequest.ProtoReflect.Descriptor instead. -func (*DescribeRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{28} -} - -// DescribeResponse reports this plugin build's own identity. See the -// Describe RPC comment on MemoryService above. -type DescribeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // This plugin build's identity: name, version, source, category, - // protocol_version. - Producer *v12.ProducerRef `protobuf:"bytes,1,opt,name=producer,proto3" json:"producer,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DescribeResponse) Reset() { - *x = DescribeResponse{} - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[29] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DescribeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DescribeResponse) ProtoMessage() {} - -func (x *DescribeResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_memory_v1_memory_proto_msgTypes[29] - 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 DescribeResponse.ProtoReflect.Descriptor instead. -func (*DescribeResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_memory_v1_memory_proto_rawDescGZIP(), []int{29} -} - -func (x *DescribeResponse) GetProducer() *v12.ProducerRef { - if x != nil { - return x.Producer - } - return nil -} - -var File_pluggableharness_memory_v1_memory_proto protoreflect.FileDescriptor - -const file_pluggableharness_memory_v1_memory_proto_rawDesc = "" + - "\n" + - "'pluggableharness/memory/v1/memory.proto\x12\x1apluggableharness.memory.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a'pluggableharness/common/v1/common.proto\x1a'pluggableharness/config/v1/config.proto\x1a)pluggableharness/content/v1/content.proto\x1a%pluggableharness/model/v1/model.proto\x1a'pluggableharness/render/v1/render.proto\x1a3pluggableharness/slashcommand/v1/slashcommand.proto\"\x18\n" + - "\x16GetCapabilitiesRequest\"\xa7\x04\n" + - "\x12MemoryCapabilities\x120\n" + - "\x14default_token_budget\x18\x01 \x01(\x03R\x12defaultTokenBudget\x12O\n" + - "\x0fsupported_types\x18\x02 \x03(\x0e2&.pluggableharness.memory.v1.MemoryTypeR\x0esupportedTypes\x12R\n" + - "\x10supported_scopes\x18\x03 \x03(\x0e2'.pluggableharness.memory.v1.MemoryScopeR\x0fsupportedScopes\x125\n" + - "\x16ratification_supported\x18\x04 \x01(\bR\x15ratificationSupported\x12Y\n" + - "\x0eslash_commands\x18\x05 \x03(\v22.pluggableharness.slashcommand.v1.SlashCommandSpecR\rslashCommands\x12M\n" + - "\rconfig_schema\x18\x06 \x01(\v2(.pluggableharness.config.v1.ConfigSchemaR\fconfigSchema\x12Y\n" + - "\x15supported_hook_points\x18\a \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPoints\"m\n" + - "\x17GetCapabilitiesResponse\x12R\n" + - "\fcapabilities\x18\x01 \x01(\v2..pluggableharness.memory.v1.MemoryCapabilitiesR\fcapabilities\"C\n" + - "\x10ConfigureRequest\x12/\n" + - "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06config\"\x13\n" + - "\x11ConfigureResponse\"\xc5\x03\n" + - "\rRecallRequest\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\x12\x17\n" + - "\aturn_id\x18\x02 \x01(\tR\x06turnId\x12!\n" + - "\ftoken_budget\x18\x03 \x01(\x03R\vtokenBudget\x12I\n" + - "\fmodel_target\x18\x04 \x01(\v2&.pluggableharness.model.v1.ModelTargetR\vmodelTarget\x12#\n" + - "\rfiles_touched\x18\x05 \x03(\tR\ffilesTouched\x12+\n" + - "\x11working_directory\x18\x06 \x01(\tR\x10workingDirectory\x12G\n" + - "\vtype_filter\x18\a \x03(\x0e2&.pluggableharness.memory.v1.MemoryTypeR\n" + - "typeFilter\x12J\n" + - "\fscope_filter\x18\b \x03(\x0e2'.pluggableharness.memory.v1.MemoryScopeR\vscopeFilter\x12'\n" + - "\x0finclude_pending\x18\t \x01(\bR\x0eincludePending\"T\n" + - "\x0eRecallResponse\x12B\n" + - "\arecords\x18\x01 \x03(\v2(.pluggableharness.memory.v1.MemoryRecordR\arecords\"\xe4\x04\n" + - "\fMemoryRecord\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12:\n" + - "\x04type\x18\x02 \x01(\x0e2&.pluggableharness.memory.v1.MemoryTypeR\x04type\x12=\n" + - "\x05scope\x18\x03 \x01(\x0e2'.pluggableharness.memory.v1.MemoryScopeR\x05scope\x12\x14\n" + - "\x05title\x18\x04 \x01(\tR\x05title\x12C\n" + - "\acontent\x18\x05 \x03(\v2).pluggableharness.content.v1.ContentBlockR\acontent\x12\x16\n" + - "\x06tokens\x18\x06 \x01(\x03R\x06tokens\x12@\n" + - "\x06status\x18\a \x01(\x0e2(.pluggableharness.memory.v1.RecordStatusR\x06status\x12\x14\n" + - "\x05links\x18\b \x03(\tR\x05links\x129\n" + - "\n" + - "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + - "\n" + - "updated_at\x18\n" + - " \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12F\n" + - "\n" + - "provenance\x18\v \x01(\v2&.pluggableharness.memory.v1.ProvenanceR\n" + - "provenance\x12,\n" + - "\x0frelevance_score\x18\f \x01(\x01H\x00R\x0erelevanceScore\x88\x01\x01B\x12\n" + - "\x10_relevance_score\"\x97\x01\n" + - "\n" + - "Provenance\x12*\n" + - "\x11source_session_id\x18\x01 \x01(\tR\x0fsourceSessionId\x12)\n" + - "\x0esource_turn_id\x18\x02 \x01(\tH\x00R\fsourceTurnId\x88\x01\x01\x12\x1f\n" + - "\vrecorded_by\x18\x03 \x01(\tR\n" + - "recordedByB\x11\n" + - "\x0f_source_turn_id\"\x81\x02\n" + - "\rRecordRequest\x12:\n" + - "\x04type\x18\x01 \x01(\x0e2&.pluggableharness.memory.v1.MemoryTypeR\x04type\x12=\n" + - "\x05scope\x18\x02 \x01(\x0e2'.pluggableharness.memory.v1.MemoryScopeR\x05scope\x12\x13\n" + - "\x02id\x18\x03 \x01(\tH\x00R\x02id\x88\x01\x01\x12\x14\n" + - "\x05title\x18\x04 \x01(\tR\x05title\x12C\n" + - "\acontent\x18\x05 \x03(\v2).pluggableharness.content.v1.ContentBlockR\acontentB\x05\n" + - "\x03_id\"`\n" + - "\fRecordResult\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12@\n" + - "\x06status\x18\x02 \x01(\x0e2(.pluggableharness.memory.v1.RecordStatusR\x06status\"R\n" + - "\x0eRecordResponse\x12@\n" + - "\x06result\x18\x01 \x01(\v2(.pluggableharness.memory.v1.RecordResultR\x06result\"\x8f\x01\n" + - "\x13UpdateRecordRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12\x19\n" + - "\x05title\x18\x02 \x01(\tH\x00R\x05title\x88\x01\x01\x12C\n" + - "\acontent\x18\x03 \x03(\v2).pluggableharness.content.v1.ContentBlockR\acontentB\b\n" + - "\x06_title\"X\n" + - "\x14UpdateRecordResponse\x12@\n" + - "\x06result\x18\x01 \x01(\v2(.pluggableharness.memory.v1.RecordResultR\x06result\"%\n" + - "\x13DeleteRecordRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"(\n" + - "\fDeleteResult\x12\x18\n" + - "\adeleted\x18\x01 \x01(\bR\adeleted\"X\n" + - "\x14DeleteRecordResponse\x12@\n" + - "\x06result\x18\x01 \x01(\v2(.pluggableharness.memory.v1.DeleteResultR\x06result\"&\n" + - "\x14ApproveRecordRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"Y\n" + - "\x15ApproveRecordResponse\x12@\n" + - "\x06result\x18\x01 \x01(\v2(.pluggableharness.memory.v1.RecordResultR\x06result\"%\n" + - "\x13RejectRecordRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"X\n" + - "\x14RejectRecordResponse\x12@\n" + - "\x06result\x18\x01 \x01(\v2(.pluggableharness.memory.v1.DeleteResultR\x06result\"\x92\x01\n" + - "\vMemoryError\x12K\n" + - "\bcategory\x18\x01 \x01(\x0e2/.pluggableharness.memory.v1.MemoryErrorCategoryR\bcategory\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\x12\x1c\n" + - "\tretryable\x18\x03 \x01(\bR\tretryable\"P\n" + - "\rRenderRequest\x12\x18\n" + - "\apayload\x18\x01 \x01(\fR\apayload\x12%\n" + - "\x0eschema_version\x18\x02 \x01(\tR\rschemaVersion\"L\n" + - "\x0eRenderResponse\x12:\n" + - "\x04tree\x18\x01 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\x04tree\"\xcb\x02\n" + - "\x12ListRecordsRequest\x12G\n" + - "\vtype_filter\x18\x01 \x03(\x0e2&.pluggableharness.memory.v1.MemoryTypeR\n" + - "typeFilter\x12J\n" + - "\fscope_filter\x18\x02 \x03(\x0e2'.pluggableharness.memory.v1.MemoryScopeR\vscopeFilter\x12R\n" + - "\rstatus_filter\x18\x03 \x01(\x0e2(.pluggableharness.memory.v1.RecordStatusH\x00R\fstatusFilter\x88\x01\x01\x12\x1b\n" + - "\tpage_size\x18\x04 \x01(\x05R\bpageSize\x12\x1d\n" + - "\n" + - "page_token\x18\x05 \x01(\tR\tpageTokenB\x10\n" + - "\x0e_status_filter\"\x81\x01\n" + - "\x13ListRecordsResponse\x12B\n" + - "\arecords\x18\x01 \x03(\v2(.pluggableharness.memory.v1.MemoryRecordR\arecords\x12&\n" + - "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\"\n" + - "\x10GetRecordRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"U\n" + - "\x11GetRecordResponse\x12@\n" + - "\x06record\x18\x01 \x01(\v2(.pluggableharness.memory.v1.MemoryRecordR\x06record\"\x11\n" + - "\x0fDescribeRequest\"W\n" + - "\x10DescribeResponse\x12C\n" + - "\bproducer\x18\x01 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\bproducer*\x8d\x01\n" + - "\n" + - "MemoryType\x12\x1b\n" + - "\x17MEMORY_TYPE_UNSPECIFIED\x10\x00\x12\x14\n" + - "\x10MEMORY_TYPE_USER\x10\x01\x12\x18\n" + - "\x14MEMORY_TYPE_FEEDBACK\x10\x02\x12\x17\n" + - "\x13MEMORY_TYPE_PROJECT\x10\x03\x12\x19\n" + - "\x15MEMORY_TYPE_REFERENCE\x10\x04*x\n" + - "\vMemoryScope\x12\x1c\n" + - "\x18MEMORY_SCOPE_UNSPECIFIED\x10\x00\x12\x18\n" + - "\x14MEMORY_SCOPE_SESSION\x10\x01\x12\x18\n" + - "\x14MEMORY_SCOPE_PROJECT\x10\x02\x12\x17\n" + - "\x13MEMORY_SCOPE_GLOBAL\x10\x03*e\n" + - "\fRecordStatus\x12\x1d\n" + - "\x19RECORD_STATUS_UNSPECIFIED\x10\x00\x12\x1b\n" + - "\x17RECORD_STATUS_CANONICAL\x10\x01\x12\x19\n" + - "\x15RECORD_STATUS_PENDING\x10\x02*\xe2\x02\n" + - "\x13MemoryErrorCategory\x12%\n" + - "!MEMORY_ERROR_CATEGORY_UNSPECIFIED\x10\x00\x12#\n" + - "\x1fMEMORY_ERROR_CATEGORY_NOT_FOUND\x10\x01\x12&\n" + - "\"MEMORY_ERROR_CATEGORY_INVALID_TYPE\x10\x02\x122\n" + - ".MEMORY_ERROR_CATEGORY_RATIFICATION_UNSUPPORTED\x10\x03\x12)\n" + - "%MEMORY_ERROR_CATEGORY_BUDGET_EXCEEDED\x10\x04\x12,\n" + - "(MEMORY_ERROR_CATEGORY_SOURCE_UNAVAILABLE\x10\x05\x12!\n" + - "\x1dMEMORY_ERROR_CATEGORY_UNKNOWN\x10\x06\x12'\n" + - "#MEMORY_ERROR_CATEGORY_INVALID_SCOPE\x10\a2\xa8\n" + - "\n" + - "\rMemoryService\x12z\n" + - "\x0fGetCapabilities\x122.pluggableharness.memory.v1.GetCapabilitiesRequest\x1a3.pluggableharness.memory.v1.GetCapabilitiesResponse\x12h\n" + - "\tConfigure\x12,.pluggableharness.memory.v1.ConfigureRequest\x1a-.pluggableharness.memory.v1.ConfigureResponse\x12_\n" + - "\x06Recall\x12).pluggableharness.memory.v1.RecallRequest\x1a*.pluggableharness.memory.v1.RecallResponse\x12_\n" + - "\x06Record\x12).pluggableharness.memory.v1.RecordRequest\x1a*.pluggableharness.memory.v1.RecordResponse\x12q\n" + - "\fUpdateRecord\x12/.pluggableharness.memory.v1.UpdateRecordRequest\x1a0.pluggableharness.memory.v1.UpdateRecordResponse\x12q\n" + - "\fDeleteRecord\x12/.pluggableharness.memory.v1.DeleteRecordRequest\x1a0.pluggableharness.memory.v1.DeleteRecordResponse\x12t\n" + - "\rApproveRecord\x120.pluggableharness.memory.v1.ApproveRecordRequest\x1a1.pluggableharness.memory.v1.ApproveRecordResponse\x12q\n" + - "\fRejectRecord\x12/.pluggableharness.memory.v1.RejectRecordRequest\x1a0.pluggableharness.memory.v1.RejectRecordResponse\x12_\n" + - "\x06Render\x12).pluggableharness.memory.v1.RenderRequest\x1a*.pluggableharness.memory.v1.RenderResponse\x12n\n" + - "\vListRecords\x12..pluggableharness.memory.v1.ListRecordsRequest\x1a/.pluggableharness.memory.v1.ListRecordsResponse\x12h\n" + - "\tGetRecord\x12,.pluggableharness.memory.v1.GetRecordRequest\x1a-.pluggableharness.memory.v1.GetRecordResponse\x12e\n" + - "\bDescribe\x12+.pluggableharness.memory.v1.DescribeRequest\x1a,.pluggableharness.memory.v1.DescribeResponseB@Z>github.com/pluggableharness/agent/pkg/memory/proto/v1;memoryv1b\x06proto3" - -var ( - file_pluggableharness_memory_v1_memory_proto_rawDescOnce sync.Once - file_pluggableharness_memory_v1_memory_proto_rawDescData []byte -) - -func file_pluggableharness_memory_v1_memory_proto_rawDescGZIP() []byte { - file_pluggableharness_memory_v1_memory_proto_rawDescOnce.Do(func() { - file_pluggableharness_memory_v1_memory_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_memory_v1_memory_proto_rawDesc), len(file_pluggableharness_memory_v1_memory_proto_rawDesc))) - }) - return file_pluggableharness_memory_v1_memory_proto_rawDescData -} - -var file_pluggableharness_memory_v1_memory_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_pluggableharness_memory_v1_memory_proto_msgTypes = make([]protoimpl.MessageInfo, 30) -var file_pluggableharness_memory_v1_memory_proto_goTypes = []any{ - (MemoryType)(0), // 0: pluggableharness.memory.v1.MemoryType - (MemoryScope)(0), // 1: pluggableharness.memory.v1.MemoryScope - (RecordStatus)(0), // 2: pluggableharness.memory.v1.RecordStatus - (MemoryErrorCategory)(0), // 3: pluggableharness.memory.v1.MemoryErrorCategory - (*GetCapabilitiesRequest)(nil), // 4: pluggableharness.memory.v1.GetCapabilitiesRequest - (*MemoryCapabilities)(nil), // 5: pluggableharness.memory.v1.MemoryCapabilities - (*GetCapabilitiesResponse)(nil), // 6: pluggableharness.memory.v1.GetCapabilitiesResponse - (*ConfigureRequest)(nil), // 7: pluggableharness.memory.v1.ConfigureRequest - (*ConfigureResponse)(nil), // 8: pluggableharness.memory.v1.ConfigureResponse - (*RecallRequest)(nil), // 9: pluggableharness.memory.v1.RecallRequest - (*RecallResponse)(nil), // 10: pluggableharness.memory.v1.RecallResponse - (*MemoryRecord)(nil), // 11: pluggableharness.memory.v1.MemoryRecord - (*Provenance)(nil), // 12: pluggableharness.memory.v1.Provenance - (*RecordRequest)(nil), // 13: pluggableharness.memory.v1.RecordRequest - (*RecordResult)(nil), // 14: pluggableharness.memory.v1.RecordResult - (*RecordResponse)(nil), // 15: pluggableharness.memory.v1.RecordResponse - (*UpdateRecordRequest)(nil), // 16: pluggableharness.memory.v1.UpdateRecordRequest - (*UpdateRecordResponse)(nil), // 17: pluggableharness.memory.v1.UpdateRecordResponse - (*DeleteRecordRequest)(nil), // 18: pluggableharness.memory.v1.DeleteRecordRequest - (*DeleteResult)(nil), // 19: pluggableharness.memory.v1.DeleteResult - (*DeleteRecordResponse)(nil), // 20: pluggableharness.memory.v1.DeleteRecordResponse - (*ApproveRecordRequest)(nil), // 21: pluggableharness.memory.v1.ApproveRecordRequest - (*ApproveRecordResponse)(nil), // 22: pluggableharness.memory.v1.ApproveRecordResponse - (*RejectRecordRequest)(nil), // 23: pluggableharness.memory.v1.RejectRecordRequest - (*RejectRecordResponse)(nil), // 24: pluggableharness.memory.v1.RejectRecordResponse - (*MemoryError)(nil), // 25: pluggableharness.memory.v1.MemoryError - (*RenderRequest)(nil), // 26: pluggableharness.memory.v1.RenderRequest - (*RenderResponse)(nil), // 27: pluggableharness.memory.v1.RenderResponse - (*ListRecordsRequest)(nil), // 28: pluggableharness.memory.v1.ListRecordsRequest - (*ListRecordsResponse)(nil), // 29: pluggableharness.memory.v1.ListRecordsResponse - (*GetRecordRequest)(nil), // 30: pluggableharness.memory.v1.GetRecordRequest - (*GetRecordResponse)(nil), // 31: pluggableharness.memory.v1.GetRecordResponse - (*DescribeRequest)(nil), // 32: pluggableharness.memory.v1.DescribeRequest - (*DescribeResponse)(nil), // 33: pluggableharness.memory.v1.DescribeResponse - (*v1.SlashCommandSpec)(nil), // 34: pluggableharness.slashcommand.v1.SlashCommandSpec - (*v11.ConfigSchema)(nil), // 35: pluggableharness.config.v1.ConfigSchema - (v12.HookPoint)(0), // 36: pluggableharness.common.v1.HookPoint - (*structpb.Struct)(nil), // 37: google.protobuf.Struct - (*v13.ModelTarget)(nil), // 38: pluggableharness.model.v1.ModelTarget - (*v14.ContentBlock)(nil), // 39: pluggableharness.content.v1.ContentBlock - (*timestamppb.Timestamp)(nil), // 40: google.protobuf.Timestamp - (*v15.RenderTree)(nil), // 41: pluggableharness.render.v1.RenderTree - (*v12.ProducerRef)(nil), // 42: pluggableharness.common.v1.ProducerRef -} -var file_pluggableharness_memory_v1_memory_proto_depIdxs = []int32{ - 0, // 0: pluggableharness.memory.v1.MemoryCapabilities.supported_types:type_name -> pluggableharness.memory.v1.MemoryType - 1, // 1: pluggableharness.memory.v1.MemoryCapabilities.supported_scopes:type_name -> pluggableharness.memory.v1.MemoryScope - 34, // 2: pluggableharness.memory.v1.MemoryCapabilities.slash_commands:type_name -> pluggableharness.slashcommand.v1.SlashCommandSpec - 35, // 3: pluggableharness.memory.v1.MemoryCapabilities.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema - 36, // 4: pluggableharness.memory.v1.MemoryCapabilities.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint - 5, // 5: pluggableharness.memory.v1.GetCapabilitiesResponse.capabilities:type_name -> pluggableharness.memory.v1.MemoryCapabilities - 37, // 6: pluggableharness.memory.v1.ConfigureRequest.config:type_name -> google.protobuf.Struct - 38, // 7: pluggableharness.memory.v1.RecallRequest.model_target:type_name -> pluggableharness.model.v1.ModelTarget - 0, // 8: pluggableharness.memory.v1.RecallRequest.type_filter:type_name -> pluggableharness.memory.v1.MemoryType - 1, // 9: pluggableharness.memory.v1.RecallRequest.scope_filter:type_name -> pluggableharness.memory.v1.MemoryScope - 11, // 10: pluggableharness.memory.v1.RecallResponse.records:type_name -> pluggableharness.memory.v1.MemoryRecord - 0, // 11: pluggableharness.memory.v1.MemoryRecord.type:type_name -> pluggableharness.memory.v1.MemoryType - 1, // 12: pluggableharness.memory.v1.MemoryRecord.scope:type_name -> pluggableharness.memory.v1.MemoryScope - 39, // 13: pluggableharness.memory.v1.MemoryRecord.content:type_name -> pluggableharness.content.v1.ContentBlock - 2, // 14: pluggableharness.memory.v1.MemoryRecord.status:type_name -> pluggableharness.memory.v1.RecordStatus - 40, // 15: pluggableharness.memory.v1.MemoryRecord.created_at:type_name -> google.protobuf.Timestamp - 40, // 16: pluggableharness.memory.v1.MemoryRecord.updated_at:type_name -> google.protobuf.Timestamp - 12, // 17: pluggableharness.memory.v1.MemoryRecord.provenance:type_name -> pluggableharness.memory.v1.Provenance - 0, // 18: pluggableharness.memory.v1.RecordRequest.type:type_name -> pluggableharness.memory.v1.MemoryType - 1, // 19: pluggableharness.memory.v1.RecordRequest.scope:type_name -> pluggableharness.memory.v1.MemoryScope - 39, // 20: pluggableharness.memory.v1.RecordRequest.content:type_name -> pluggableharness.content.v1.ContentBlock - 2, // 21: pluggableharness.memory.v1.RecordResult.status:type_name -> pluggableharness.memory.v1.RecordStatus - 14, // 22: pluggableharness.memory.v1.RecordResponse.result:type_name -> pluggableharness.memory.v1.RecordResult - 39, // 23: pluggableharness.memory.v1.UpdateRecordRequest.content:type_name -> pluggableharness.content.v1.ContentBlock - 14, // 24: pluggableharness.memory.v1.UpdateRecordResponse.result:type_name -> pluggableharness.memory.v1.RecordResult - 19, // 25: pluggableharness.memory.v1.DeleteRecordResponse.result:type_name -> pluggableharness.memory.v1.DeleteResult - 14, // 26: pluggableharness.memory.v1.ApproveRecordResponse.result:type_name -> pluggableharness.memory.v1.RecordResult - 19, // 27: pluggableharness.memory.v1.RejectRecordResponse.result:type_name -> pluggableharness.memory.v1.DeleteResult - 3, // 28: pluggableharness.memory.v1.MemoryError.category:type_name -> pluggableharness.memory.v1.MemoryErrorCategory - 41, // 29: pluggableharness.memory.v1.RenderResponse.tree:type_name -> pluggableharness.render.v1.RenderTree - 0, // 30: pluggableharness.memory.v1.ListRecordsRequest.type_filter:type_name -> pluggableharness.memory.v1.MemoryType - 1, // 31: pluggableharness.memory.v1.ListRecordsRequest.scope_filter:type_name -> pluggableharness.memory.v1.MemoryScope - 2, // 32: pluggableharness.memory.v1.ListRecordsRequest.status_filter:type_name -> pluggableharness.memory.v1.RecordStatus - 11, // 33: pluggableharness.memory.v1.ListRecordsResponse.records:type_name -> pluggableharness.memory.v1.MemoryRecord - 11, // 34: pluggableharness.memory.v1.GetRecordResponse.record:type_name -> pluggableharness.memory.v1.MemoryRecord - 42, // 35: pluggableharness.memory.v1.DescribeResponse.producer:type_name -> pluggableharness.common.v1.ProducerRef - 4, // 36: pluggableharness.memory.v1.MemoryService.GetCapabilities:input_type -> pluggableharness.memory.v1.GetCapabilitiesRequest - 7, // 37: pluggableharness.memory.v1.MemoryService.Configure:input_type -> pluggableharness.memory.v1.ConfigureRequest - 9, // 38: pluggableharness.memory.v1.MemoryService.Recall:input_type -> pluggableharness.memory.v1.RecallRequest - 13, // 39: pluggableharness.memory.v1.MemoryService.Record:input_type -> pluggableharness.memory.v1.RecordRequest - 16, // 40: pluggableharness.memory.v1.MemoryService.UpdateRecord:input_type -> pluggableharness.memory.v1.UpdateRecordRequest - 18, // 41: pluggableharness.memory.v1.MemoryService.DeleteRecord:input_type -> pluggableharness.memory.v1.DeleteRecordRequest - 21, // 42: pluggableharness.memory.v1.MemoryService.ApproveRecord:input_type -> pluggableharness.memory.v1.ApproveRecordRequest - 23, // 43: pluggableharness.memory.v1.MemoryService.RejectRecord:input_type -> pluggableharness.memory.v1.RejectRecordRequest - 26, // 44: pluggableharness.memory.v1.MemoryService.Render:input_type -> pluggableharness.memory.v1.RenderRequest - 28, // 45: pluggableharness.memory.v1.MemoryService.ListRecords:input_type -> pluggableharness.memory.v1.ListRecordsRequest - 30, // 46: pluggableharness.memory.v1.MemoryService.GetRecord:input_type -> pluggableharness.memory.v1.GetRecordRequest - 32, // 47: pluggableharness.memory.v1.MemoryService.Describe:input_type -> pluggableharness.memory.v1.DescribeRequest - 6, // 48: pluggableharness.memory.v1.MemoryService.GetCapabilities:output_type -> pluggableharness.memory.v1.GetCapabilitiesResponse - 8, // 49: pluggableharness.memory.v1.MemoryService.Configure:output_type -> pluggableharness.memory.v1.ConfigureResponse - 10, // 50: pluggableharness.memory.v1.MemoryService.Recall:output_type -> pluggableharness.memory.v1.RecallResponse - 15, // 51: pluggableharness.memory.v1.MemoryService.Record:output_type -> pluggableharness.memory.v1.RecordResponse - 17, // 52: pluggableharness.memory.v1.MemoryService.UpdateRecord:output_type -> pluggableharness.memory.v1.UpdateRecordResponse - 20, // 53: pluggableharness.memory.v1.MemoryService.DeleteRecord:output_type -> pluggableharness.memory.v1.DeleteRecordResponse - 22, // 54: pluggableharness.memory.v1.MemoryService.ApproveRecord:output_type -> pluggableharness.memory.v1.ApproveRecordResponse - 24, // 55: pluggableharness.memory.v1.MemoryService.RejectRecord:output_type -> pluggableharness.memory.v1.RejectRecordResponse - 27, // 56: pluggableharness.memory.v1.MemoryService.Render:output_type -> pluggableharness.memory.v1.RenderResponse - 29, // 57: pluggableharness.memory.v1.MemoryService.ListRecords:output_type -> pluggableharness.memory.v1.ListRecordsResponse - 31, // 58: pluggableharness.memory.v1.MemoryService.GetRecord:output_type -> pluggableharness.memory.v1.GetRecordResponse - 33, // 59: pluggableharness.memory.v1.MemoryService.Describe:output_type -> pluggableharness.memory.v1.DescribeResponse - 48, // [48:60] is the sub-list for method output_type - 36, // [36:48] is the sub-list for method input_type - 36, // [36:36] is the sub-list for extension type_name - 36, // [36:36] is the sub-list for extension extendee - 0, // [0:36] is the sub-list for field type_name -} - -func init() { file_pluggableharness_memory_v1_memory_proto_init() } -func file_pluggableharness_memory_v1_memory_proto_init() { - if File_pluggableharness_memory_v1_memory_proto != nil { - return - } - file_pluggableharness_memory_v1_memory_proto_msgTypes[7].OneofWrappers = []any{} - file_pluggableharness_memory_v1_memory_proto_msgTypes[8].OneofWrappers = []any{} - file_pluggableharness_memory_v1_memory_proto_msgTypes[9].OneofWrappers = []any{} - file_pluggableharness_memory_v1_memory_proto_msgTypes[12].OneofWrappers = []any{} - file_pluggableharness_memory_v1_memory_proto_msgTypes[24].OneofWrappers = []any{} - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_memory_v1_memory_proto_rawDesc), len(file_pluggableharness_memory_v1_memory_proto_rawDesc)), - NumEnums: 4, - NumMessages: 30, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_pluggableharness_memory_v1_memory_proto_goTypes, - DependencyIndexes: file_pluggableharness_memory_v1_memory_proto_depIdxs, - EnumInfos: file_pluggableharness_memory_v1_memory_proto_enumTypes, - MessageInfos: file_pluggableharness_memory_v1_memory_proto_msgTypes, - }.Build() - File_pluggableharness_memory_v1_memory_proto = out.File - file_pluggableharness_memory_v1_memory_proto_goTypes = nil - file_pluggableharness_memory_v1_memory_proto_depIdxs = nil -} diff --git a/pkg/memory/proto/v1/rpc_request.pb.go b/pkg/memory/proto/v1/rpc_request.pb.go new file mode 100644 index 0000000..41a96c8 --- /dev/null +++ b/pkg/memory/proto/v1/rpc_request.pb.go @@ -0,0 +1,913 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/memory/v1/rpc_request.proto + +package memoryv1 + +import ( + v11 "github.com/pluggableharness/agent/pkg/content/proto/v1" + v1 "github.com/pluggableharness/agent/pkg/model/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// GetCapabilitiesRequest carries no fields; GetCapabilities takes no +// parameters beyond the plugin handshake already having occurred. +type GetCapabilitiesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCapabilitiesRequest) Reset() { + *x = GetCapabilitiesRequest{} + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCapabilitiesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCapabilitiesRequest) ProtoMessage() {} + +func (x *GetCapabilitiesRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[0] + 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 GetCapabilitiesRequest.ProtoReflect.Descriptor instead. +func (*GetCapabilitiesRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_request_proto_rawDescGZIP(), []int{0} +} + +// ConfigureRequest wraps this provider's already-decoded agent.hcl config +// block. memory.md §5. +type ConfigureRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The decoded config, per the schema this provider advertised in + // MemoryCapabilities.config_schema (configuration.md §4). Already + // decoded from HCL/cty by the kernel — this is a Struct because the + // shape is genuinely provider-defined at the proto level (see + // .claude/rules/proto.md's Struct carve-out), not because the value + // itself is untyped. + Config *structpb.Struct `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureRequest) Reset() { + *x = ConfigureRequest{} + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureRequest) ProtoMessage() {} + +func (x *ConfigureRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[1] + 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 ConfigureRequest.ProtoReflect.Descriptor instead. +func (*ConfigureRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_request_proto_rawDescGZIP(), []int{1} +} + +func (x *ConfigureRequest) GetConfig() *structpb.Struct { + if x != nil { + return x.Config + } + return nil +} + +// RecallRequest is the read-side query, issued at context-assemble time. +// memory.md §6. +type RecallRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The requesting session's id. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // The current turn within that session. A ULID, standardized across the + // whole protocol (matches plan.v1's turn_id field and context.v1's + // ContextRequest.turn_id, same treatment). Same field number as the + // retired int64 turn-number predecessor field; the rename is a + // sanctioned pre-release wire change, not a v2 bump. + TurnId string `protobuf:"bytes,2,opt,name=turn_id,json=turnId,proto3" json:"turn_id,omitempty"` + // The token budget this Recall call MUST self-truncate its returned + // records to. Competes for the same budget pool as context providers, + // resolved the same way context.md §6 resolves a context provider's cap. + TokenBudget int64 `protobuf:"varint,3,opt,name=token_budget,json=tokenBudget,proto3" json:"token_budget,omitempty"` + // The model this recall is being assembled for, mirroring context.md + // §4's ContextRequest field. Lets a provider pass a precise model + // reference into the CountTokens kernel callback (kernel-callbacks.md + // §2) — added per kernel-callbacks.md §6's correction. MUST be set. + ModelTarget *v1.ModelTarget `protobuf:"bytes,4,opt,name=model_target,json=modelTarget,proto3" json:"model_target,omitempty"` + // Paths of files touched so far this turn, mirroring context.md §4's + // field of the same name. MAY be empty. + FilesTouched []string `protobuf:"bytes,5,rep,name=files_touched,json=filesTouched,proto3" json:"files_touched,omitempty"` + // The session's current working directory. + WorkingDirectory string `protobuf:"bytes,6,opt,name=working_directory,json=workingDirectory,proto3" json:"working_directory,omitempty"` + // Restricts results to these MemoryTypes. MAY be empty, meaning all + // types this provider supports. + TypeFilter []MemoryType `protobuf:"varint,7,rep,packed,name=type_filter,json=typeFilter,proto3,enum=pluggableharness.memory.v1.MemoryType" json:"type_filter,omitempty"` + // Restricts results to these MemoryScopes. MAY be empty, meaning all + // scopes this provider supports. + ScopeFilter []MemoryScope `protobuf:"varint,8,rep,packed,name=scope_filter,json=scopeFilter,proto3,enum=pluggableharness.memory.v1.MemoryScope" json:"scope_filter,omitempty"` + // Whether PENDING-status records may be included in the response. MUST + // default to false — a PENDING record MUST NOT surface through ordinary + // recall unless this is true (memory.md §6, §8). + IncludePending bool `protobuf:"varint,9,opt,name=include_pending,json=includePending,proto3" json:"include_pending,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecallRequest) Reset() { + *x = RecallRequest{} + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecallRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecallRequest) ProtoMessage() {} + +func (x *RecallRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[2] + 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 RecallRequest.ProtoReflect.Descriptor instead. +func (*RecallRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_request_proto_rawDescGZIP(), []int{2} +} + +func (x *RecallRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *RecallRequest) GetTurnId() string { + if x != nil { + return x.TurnId + } + return "" +} + +func (x *RecallRequest) GetTokenBudget() int64 { + if x != nil { + return x.TokenBudget + } + return 0 +} + +func (x *RecallRequest) GetModelTarget() *v1.ModelTarget { + if x != nil { + return x.ModelTarget + } + return nil +} + +func (x *RecallRequest) GetFilesTouched() []string { + if x != nil { + return x.FilesTouched + } + return nil +} + +func (x *RecallRequest) GetWorkingDirectory() string { + if x != nil { + return x.WorkingDirectory + } + return "" +} + +func (x *RecallRequest) GetTypeFilter() []MemoryType { + if x != nil { + return x.TypeFilter + } + return nil +} + +func (x *RecallRequest) GetScopeFilter() []MemoryScope { + if x != nil { + return x.ScopeFilter + } + return nil +} + +func (x *RecallRequest) GetIncludePending() bool { + if x != nil { + return x.IncludePending + } + return false +} + +// RecordRequest creates a new record. memory.md §7. +type RecordRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // This record's fixed taxonomy classification. MUST be set. + Type MemoryType `protobuf:"varint,1,opt,name=type,proto3,enum=pluggableharness.memory.v1.MemoryType" json:"type,omitempty"` + // This record's visibility scope. MUST be set. + Scope MemoryScope `protobuf:"varint,2,opt,name=scope,proto3,enum=pluggableharness.memory.v1.MemoryScope" json:"scope,omitempty"` + // An author-suggested slug. MAY be omitted, in which case the provider + // derives one from content; the kernel disambiguates collisions with a + // numeric suffix (memory.md §7) rather than overwriting or rejecting. + Id *string `protobuf:"bytes,3,opt,name=id,proto3,oneof" json:"id,omitempty"` + // Human-readable title. + Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` + // The record's content. + Content []*v11.ContentBlock `protobuf:"bytes,5,rep,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecordRequest) Reset() { + *x = RecordRequest{} + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecordRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecordRequest) ProtoMessage() {} + +func (x *RecordRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[3] + 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 RecordRequest.ProtoReflect.Descriptor instead. +func (*RecordRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_request_proto_rawDescGZIP(), []int{3} +} + +func (x *RecordRequest) GetType() MemoryType { + if x != nil { + return x.Type + } + return MemoryType_MEMORY_TYPE_UNSPECIFIED +} + +func (x *RecordRequest) GetScope() MemoryScope { + if x != nil { + return x.Scope + } + return MemoryScope_MEMORY_SCOPE_UNSPECIFIED +} + +func (x *RecordRequest) GetId() string { + if x != nil && x.Id != nil { + return *x.Id + } + return "" +} + +func (x *RecordRequest) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *RecordRequest) GetContent() []*v11.ContentBlock { + if x != nil { + return x.Content + } + return nil +} + +// UpdateRecordRequest replaces an existing record's title/content +// wholesale. memory.md §7. +type UpdateRecordRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The existing record's id. MUST match an existing record, or the call + // fails with a structured MemoryError. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // The record's new title. Unset leaves the existing title unchanged. + Title *string `protobuf:"bytes,2,opt,name=title,proto3,oneof" json:"title,omitempty"` + // The record's new content, replacing the existing content wholesale — + // not a patch. MUST be set. + Content []*v11.ContentBlock `protobuf:"bytes,3,rep,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateRecordRequest) Reset() { + *x = UpdateRecordRequest{} + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateRecordRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateRecordRequest) ProtoMessage() {} + +func (x *UpdateRecordRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[4] + 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 UpdateRecordRequest.ProtoReflect.Descriptor instead. +func (*UpdateRecordRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_request_proto_rawDescGZIP(), []int{4} +} + +func (x *UpdateRecordRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *UpdateRecordRequest) GetTitle() string { + if x != nil && x.Title != nil { + return *x.Title + } + return "" +} + +func (x *UpdateRecordRequest) GetContent() []*v11.ContentBlock { + if x != nil { + return x.Content + } + return nil +} + +// DeleteRecordRequest identifies the record to remove. memory.md §7. +type DeleteRecordRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The existing record's id. MUST match an existing record, or the call + // fails with a structured MemoryError. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteRecordRequest) Reset() { + *x = DeleteRecordRequest{} + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteRecordRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteRecordRequest) ProtoMessage() {} + +func (x *DeleteRecordRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_request_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 DeleteRecordRequest.ProtoReflect.Descriptor instead. +func (*DeleteRecordRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_request_proto_rawDescGZIP(), []int{5} +} + +func (x *DeleteRecordRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// ApproveRecordRequest identifies the pending record to transition to +// CANONICAL. memory.md §8. +type ApproveRecordRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The pending record's id. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApproveRecordRequest) Reset() { + *x = ApproveRecordRequest{} + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApproveRecordRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApproveRecordRequest) ProtoMessage() {} + +func (x *ApproveRecordRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_request_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 ApproveRecordRequest.ProtoReflect.Descriptor instead. +func (*ApproveRecordRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_request_proto_rawDescGZIP(), []int{6} +} + +func (x *ApproveRecordRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// RejectRecordRequest identifies the pending record to discard entirely. +// memory.md §8. +type RejectRecordRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The pending record's id. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RejectRecordRequest) Reset() { + *x = RejectRecordRequest{} + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RejectRecordRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RejectRecordRequest) ProtoMessage() {} + +func (x *RejectRecordRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[7] + 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 RejectRecordRequest.ProtoReflect.Descriptor instead. +func (*RejectRecordRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_request_proto_rawDescGZIP(), []int{7} +} + +func (x *RejectRecordRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// RenderRequest carries the opaque payload to render. memory.md §10. +type RenderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The opaque emitted payload to render — see grpc.md's Emit->Render-> + // Paint carve-out; this is the one field in this file that is + // deliberately untyped bytes rather than a concrete message. + Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` + // The schema version this payload was emitted against, so a Render + // implementation can detect drift between the version it was built + // against and the version live in a running session. See + // ../frontend/render-tree.md#schema-versioning for the canonical + // definition of this field's semantics (owned by the frontend/widget + // workstream; this field just carries the same value). + SchemaVersion string `protobuf:"bytes,2,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenderRequest) Reset() { + *x = RenderRequest{} + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenderRequest) ProtoMessage() {} + +func (x *RenderRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[8] + 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 RenderRequest.ProtoReflect.Descriptor instead. +func (*RenderRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_request_proto_rawDescGZIP(), []int{8} +} + +func (x *RenderRequest) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +func (x *RenderRequest) GetSchemaVersion() string { + if x != nil { + return x.SchemaVersion + } + return "" +} + +// ListRecordsRequest is the enumeration/audit query: paginated browsing of +// this provider's records, filterable by type/scope/status. Unlike +// RecallRequest, there is no include_pending gate — this is the +// review-inbox path (e.g. a ratification review UI or generic record +// browsing), where PENDING records ARE listable, not the budget-constrained +// per-turn recall path. memory.md §7. +type ListRecordsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Restricts results to these MemoryTypes. MAY be empty, meaning all + // types this provider supports. + TypeFilter []MemoryType `protobuf:"varint,1,rep,packed,name=type_filter,json=typeFilter,proto3,enum=pluggableharness.memory.v1.MemoryType" json:"type_filter,omitempty"` + // Restricts results to these MemoryScopes. MAY be empty, meaning all + // scopes this provider supports. + ScopeFilter []MemoryScope `protobuf:"varint,2,rep,packed,name=scope_filter,json=scopeFilter,proto3,enum=pluggableharness.memory.v1.MemoryScope" json:"scope_filter,omitempty"` + // Restricts results to this RecordStatus. Unset means both CANONICAL and + // PENDING records are eligible — this is the one path where a PENDING + // record is listable without any include_pending-style gate. + StatusFilter *RecordStatus `protobuf:"varint,3,opt,name=status_filter,json=statusFilter,proto3,enum=pluggableharness.memory.v1.RecordStatus,oneof" json:"status_filter,omitempty"` + // Maximum number of records to return in one page. + PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + // Opaque continuation token from a prior ListRecordsResponse. + // next_page_token. Empty on the first page. + PageToken string `protobuf:"bytes,5,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRecordsRequest) Reset() { + *x = ListRecordsRequest{} + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRecordsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRecordsRequest) ProtoMessage() {} + +func (x *ListRecordsRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[9] + 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 ListRecordsRequest.ProtoReflect.Descriptor instead. +func (*ListRecordsRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_request_proto_rawDescGZIP(), []int{9} +} + +func (x *ListRecordsRequest) GetTypeFilter() []MemoryType { + if x != nil { + return x.TypeFilter + } + return nil +} + +func (x *ListRecordsRequest) GetScopeFilter() []MemoryScope { + if x != nil { + return x.ScopeFilter + } + return nil +} + +func (x *ListRecordsRequest) GetStatusFilter() RecordStatus { + if x != nil && x.StatusFilter != nil { + return *x.StatusFilter + } + return RecordStatus_RECORD_STATUS_UNSPECIFIED +} + +func (x *ListRecordsRequest) GetPageSize() int32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *ListRecordsRequest) GetPageToken() string { + if x != nil { + return x.PageToken + } + return "" +} + +// GetRecordRequest identifies exactly one record to fetch by id. +type GetRecordRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The record's id. MUST match an existing record, or the call fails with + // a structured MemoryError (category NOT_FOUND). + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetRecordRequest) Reset() { + *x = GetRecordRequest{} + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetRecordRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetRecordRequest) ProtoMessage() {} + +func (x *GetRecordRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[10] + 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 GetRecordRequest.ProtoReflect.Descriptor instead. +func (*GetRecordRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_request_proto_rawDescGZIP(), []int{10} +} + +func (x *GetRecordRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// DescribeRequest carries no fields — Describe takes no request-scoped +// parameters. +type DescribeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeRequest) Reset() { + *x = DescribeRequest{} + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeRequest) ProtoMessage() {} + +func (x *DescribeRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[11] + 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 DescribeRequest.ProtoReflect.Descriptor instead. +func (*DescribeRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_request_proto_rawDescGZIP(), []int{11} +} + +var File_pluggableharness_memory_v1_rpc_request_proto protoreflect.FileDescriptor + +const file_pluggableharness_memory_v1_rpc_request_proto_rawDesc = "" + + "\n" + + ",pluggableharness/memory/v1/rpc_request.proto\x12\x1apluggableharness.memory.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a'pluggableharness/content/v1/types.proto\x1a&pluggableharness/memory/v1/types.proto\x1a%pluggableharness/model/v1/types.proto\"\x18\n" + + "\x16GetCapabilitiesRequest\"C\n" + + "\x10ConfigureRequest\x12/\n" + + "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06config\"\xc5\x03\n" + + "\rRecallRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x17\n" + + "\aturn_id\x18\x02 \x01(\tR\x06turnId\x12!\n" + + "\ftoken_budget\x18\x03 \x01(\x03R\vtokenBudget\x12I\n" + + "\fmodel_target\x18\x04 \x01(\v2&.pluggableharness.model.v1.ModelTargetR\vmodelTarget\x12#\n" + + "\rfiles_touched\x18\x05 \x03(\tR\ffilesTouched\x12+\n" + + "\x11working_directory\x18\x06 \x01(\tR\x10workingDirectory\x12G\n" + + "\vtype_filter\x18\a \x03(\x0e2&.pluggableharness.memory.v1.MemoryTypeR\n" + + "typeFilter\x12J\n" + + "\fscope_filter\x18\b \x03(\x0e2'.pluggableharness.memory.v1.MemoryScopeR\vscopeFilter\x12'\n" + + "\x0finclude_pending\x18\t \x01(\bR\x0eincludePending\"\x81\x02\n" + + "\rRecordRequest\x12:\n" + + "\x04type\x18\x01 \x01(\x0e2&.pluggableharness.memory.v1.MemoryTypeR\x04type\x12=\n" + + "\x05scope\x18\x02 \x01(\x0e2'.pluggableharness.memory.v1.MemoryScopeR\x05scope\x12\x13\n" + + "\x02id\x18\x03 \x01(\tH\x00R\x02id\x88\x01\x01\x12\x14\n" + + "\x05title\x18\x04 \x01(\tR\x05title\x12C\n" + + "\acontent\x18\x05 \x03(\v2).pluggableharness.content.v1.ContentBlockR\acontentB\x05\n" + + "\x03_id\"\x8f\x01\n" + + "\x13UpdateRecordRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x19\n" + + "\x05title\x18\x02 \x01(\tH\x00R\x05title\x88\x01\x01\x12C\n" + + "\acontent\x18\x03 \x03(\v2).pluggableharness.content.v1.ContentBlockR\acontentB\b\n" + + "\x06_title\"%\n" + + "\x13DeleteRecordRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"&\n" + + "\x14ApproveRecordRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"%\n" + + "\x13RejectRecordRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"P\n" + + "\rRenderRequest\x12\x18\n" + + "\apayload\x18\x01 \x01(\fR\apayload\x12%\n" + + "\x0eschema_version\x18\x02 \x01(\tR\rschemaVersion\"\xcb\x02\n" + + "\x12ListRecordsRequest\x12G\n" + + "\vtype_filter\x18\x01 \x03(\x0e2&.pluggableharness.memory.v1.MemoryTypeR\n" + + "typeFilter\x12J\n" + + "\fscope_filter\x18\x02 \x03(\x0e2'.pluggableharness.memory.v1.MemoryScopeR\vscopeFilter\x12R\n" + + "\rstatus_filter\x18\x03 \x01(\x0e2(.pluggableharness.memory.v1.RecordStatusH\x00R\fstatusFilter\x88\x01\x01\x12\x1b\n" + + "\tpage_size\x18\x04 \x01(\x05R\bpageSize\x12\x1d\n" + + "\n" + + "page_token\x18\x05 \x01(\tR\tpageTokenB\x10\n" + + "\x0e_status_filter\"\"\n" + + "\x10GetRecordRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\x11\n" + + "\x0fDescribeRequestB@Z>github.com/pluggableharness/agent/pkg/memory/proto/v1;memoryv1b\x06proto3" + +var ( + file_pluggableharness_memory_v1_rpc_request_proto_rawDescOnce sync.Once + file_pluggableharness_memory_v1_rpc_request_proto_rawDescData []byte +) + +func file_pluggableharness_memory_v1_rpc_request_proto_rawDescGZIP() []byte { + file_pluggableharness_memory_v1_rpc_request_proto_rawDescOnce.Do(func() { + file_pluggableharness_memory_v1_rpc_request_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_memory_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_memory_v1_rpc_request_proto_rawDesc))) + }) + return file_pluggableharness_memory_v1_rpc_request_proto_rawDescData +} + +var file_pluggableharness_memory_v1_rpc_request_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_pluggableharness_memory_v1_rpc_request_proto_goTypes = []any{ + (*GetCapabilitiesRequest)(nil), // 0: pluggableharness.memory.v1.GetCapabilitiesRequest + (*ConfigureRequest)(nil), // 1: pluggableharness.memory.v1.ConfigureRequest + (*RecallRequest)(nil), // 2: pluggableharness.memory.v1.RecallRequest + (*RecordRequest)(nil), // 3: pluggableharness.memory.v1.RecordRequest + (*UpdateRecordRequest)(nil), // 4: pluggableharness.memory.v1.UpdateRecordRequest + (*DeleteRecordRequest)(nil), // 5: pluggableharness.memory.v1.DeleteRecordRequest + (*ApproveRecordRequest)(nil), // 6: pluggableharness.memory.v1.ApproveRecordRequest + (*RejectRecordRequest)(nil), // 7: pluggableharness.memory.v1.RejectRecordRequest + (*RenderRequest)(nil), // 8: pluggableharness.memory.v1.RenderRequest + (*ListRecordsRequest)(nil), // 9: pluggableharness.memory.v1.ListRecordsRequest + (*GetRecordRequest)(nil), // 10: pluggableharness.memory.v1.GetRecordRequest + (*DescribeRequest)(nil), // 11: pluggableharness.memory.v1.DescribeRequest + (*structpb.Struct)(nil), // 12: google.protobuf.Struct + (*v1.ModelTarget)(nil), // 13: pluggableharness.model.v1.ModelTarget + (MemoryType)(0), // 14: pluggableharness.memory.v1.MemoryType + (MemoryScope)(0), // 15: pluggableharness.memory.v1.MemoryScope + (*v11.ContentBlock)(nil), // 16: pluggableharness.content.v1.ContentBlock + (RecordStatus)(0), // 17: pluggableharness.memory.v1.RecordStatus +} +var file_pluggableharness_memory_v1_rpc_request_proto_depIdxs = []int32{ + 12, // 0: pluggableharness.memory.v1.ConfigureRequest.config:type_name -> google.protobuf.Struct + 13, // 1: pluggableharness.memory.v1.RecallRequest.model_target:type_name -> pluggableharness.model.v1.ModelTarget + 14, // 2: pluggableharness.memory.v1.RecallRequest.type_filter:type_name -> pluggableharness.memory.v1.MemoryType + 15, // 3: pluggableharness.memory.v1.RecallRequest.scope_filter:type_name -> pluggableharness.memory.v1.MemoryScope + 14, // 4: pluggableharness.memory.v1.RecordRequest.type:type_name -> pluggableharness.memory.v1.MemoryType + 15, // 5: pluggableharness.memory.v1.RecordRequest.scope:type_name -> pluggableharness.memory.v1.MemoryScope + 16, // 6: pluggableharness.memory.v1.RecordRequest.content:type_name -> pluggableharness.content.v1.ContentBlock + 16, // 7: pluggableharness.memory.v1.UpdateRecordRequest.content:type_name -> pluggableharness.content.v1.ContentBlock + 14, // 8: pluggableharness.memory.v1.ListRecordsRequest.type_filter:type_name -> pluggableharness.memory.v1.MemoryType + 15, // 9: pluggableharness.memory.v1.ListRecordsRequest.scope_filter:type_name -> pluggableharness.memory.v1.MemoryScope + 17, // 10: pluggableharness.memory.v1.ListRecordsRequest.status_filter:type_name -> pluggableharness.memory.v1.RecordStatus + 11, // [11:11] is the sub-list for method output_type + 11, // [11:11] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_pluggableharness_memory_v1_rpc_request_proto_init() } +func file_pluggableharness_memory_v1_rpc_request_proto_init() { + if File_pluggableharness_memory_v1_rpc_request_proto != nil { + return + } + file_pluggableharness_memory_v1_types_proto_init() + file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[3].OneofWrappers = []any{} + file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[4].OneofWrappers = []any{} + file_pluggableharness_memory_v1_rpc_request_proto_msgTypes[9].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_memory_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_memory_v1_rpc_request_proto_rawDesc)), + NumEnums: 0, + NumMessages: 12, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_memory_v1_rpc_request_proto_goTypes, + DependencyIndexes: file_pluggableharness_memory_v1_rpc_request_proto_depIdxs, + MessageInfos: file_pluggableharness_memory_v1_rpc_request_proto_msgTypes, + }.Build() + File_pluggableharness_memory_v1_rpc_request_proto = out.File + file_pluggableharness_memory_v1_rpc_request_proto_goTypes = nil + file_pluggableharness_memory_v1_rpc_request_proto_depIdxs = nil +} diff --git a/pkg/memory/proto/v1/rpc_response.pb.go b/pkg/memory/proto/v1/rpc_response.pb.go new file mode 100644 index 0000000..7ea4524 --- /dev/null +++ b/pkg/memory/proto/v1/rpc_response.pb.go @@ -0,0 +1,698 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/memory/v1/rpc_response.proto + +package memoryv1 + +import ( + v11 "github.com/pluggableharness/agent/pkg/common/proto/v1" + v1 "github.com/pluggableharness/agent/pkg/render/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// GetCapabilitiesResponse wraps this provider's capability advertisement. +type GetCapabilitiesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // This provider's capabilities. + Capabilities *MemoryCapabilities `protobuf:"bytes,1,opt,name=capabilities,proto3" json:"capabilities,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCapabilitiesResponse) Reset() { + *x = GetCapabilitiesResponse{} + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCapabilitiesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCapabilitiesResponse) ProtoMessage() {} + +func (x *GetCapabilitiesResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[0] + 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 GetCapabilitiesResponse.ProtoReflect.Descriptor instead. +func (*GetCapabilitiesResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_response_proto_rawDescGZIP(), []int{0} +} + +func (x *GetCapabilitiesResponse) GetCapabilities() *MemoryCapabilities { + if x != nil { + return x.Capabilities + } + return nil +} + +// ConfigureResponse is empty on success. Errors surface as a gRPC status +// carrying a MemoryError in its structured detail, per grpc.md — not an +// in-band field on this message. +type ConfigureResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureResponse) Reset() { + *x = ConfigureResponse{} + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureResponse) ProtoMessage() {} + +func (x *ConfigureResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[1] + 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 ConfigureResponse.ProtoReflect.Descriptor instead. +func (*ConfigureResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_response_proto_rawDescGZIP(), []int{1} +} + +// RecallResponse carries the records this provider judges relevant to the +// requesting RecallRequest. +type RecallResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The recalled records, in this provider's own relevance order. + Records []*MemoryRecord `protobuf:"bytes,1,rep,name=records,proto3" json:"records,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecallResponse) Reset() { + *x = RecallResponse{} + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecallResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecallResponse) ProtoMessage() {} + +func (x *RecallResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[2] + 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 RecallResponse.ProtoReflect.Descriptor instead. +func (*RecallResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_response_proto_rawDescGZIP(), []int{2} +} + +func (x *RecallResponse) GetRecords() []*MemoryRecord { + if x != nil { + return x.Records + } + return nil +} + +// RecordResponse wraps Record's outcome. +type RecordResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The newly created record's outcome. + Result *RecordResult `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecordResponse) Reset() { + *x = RecordResponse{} + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecordResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecordResponse) ProtoMessage() {} + +func (x *RecordResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[3] + 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 RecordResponse.ProtoReflect.Descriptor instead. +func (*RecordResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_response_proto_rawDescGZIP(), []int{3} +} + +func (x *RecordResponse) GetResult() *RecordResult { + if x != nil { + return x.Result + } + return nil +} + +// UpdateRecordResponse wraps UpdateRecord's outcome, which is the same +// shape as Record's (memory.md §7) but kept as its own per-RPC response +// type — RecordResult itself stays a reusable domain type, not an RPC +// response type shared across RPCs. +type UpdateRecordResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The updated record's outcome. + Result *RecordResult `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateRecordResponse) Reset() { + *x = UpdateRecordResponse{} + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateRecordResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateRecordResponse) ProtoMessage() {} + +func (x *UpdateRecordResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[4] + 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 UpdateRecordResponse.ProtoReflect.Descriptor instead. +func (*UpdateRecordResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_response_proto_rawDescGZIP(), []int{4} +} + +func (x *UpdateRecordResponse) GetResult() *RecordResult { + if x != nil { + return x.Result + } + return nil +} + +// DeleteRecordResponse wraps DeleteRecord's outcome. +type DeleteRecordResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Whether the record was actually removed. + Result *DeleteResult `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteRecordResponse) Reset() { + *x = DeleteRecordResponse{} + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteRecordResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteRecordResponse) ProtoMessage() {} + +func (x *DeleteRecordResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_response_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 DeleteRecordResponse.ProtoReflect.Descriptor instead. +func (*DeleteRecordResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_response_proto_rawDescGZIP(), []int{5} +} + +func (x *DeleteRecordResponse) GetResult() *DeleteResult { + if x != nil { + return x.Result + } + return nil +} + +// ApproveRecordResponse wraps ApproveRecord's outcome, which is the same +// shape as Record's (memory.md §8) but kept as its own per-RPC response +// type — RecordResult itself stays a reusable domain type, not an RPC +// response type shared across RPCs. +type ApproveRecordResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The now-canonical record's outcome. + Result *RecordResult `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApproveRecordResponse) Reset() { + *x = ApproveRecordResponse{} + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApproveRecordResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApproveRecordResponse) ProtoMessage() {} + +func (x *ApproveRecordResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_response_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 ApproveRecordResponse.ProtoReflect.Descriptor instead. +func (*ApproveRecordResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_response_proto_rawDescGZIP(), []int{6} +} + +func (x *ApproveRecordResponse) GetResult() *RecordResult { + if x != nil { + return x.Result + } + return nil +} + +// RejectRecordResponse wraps RejectRecord's outcome, which is the same +// shape as DeleteRecord's (memory.md §8) but kept as its own per-RPC +// response type — DeleteResult itself stays a reusable domain type, not an +// RPC response type shared across RPCs. +type RejectRecordResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Whether the pending draft was actually discarded. + Result *DeleteResult `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RejectRecordResponse) Reset() { + *x = RejectRecordResponse{} + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RejectRecordResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RejectRecordResponse) ProtoMessage() {} + +func (x *RejectRecordResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[7] + 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 RejectRecordResponse.ProtoReflect.Descriptor instead. +func (*RejectRecordResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_response_proto_rawDescGZIP(), []int{7} +} + +func (x *RejectRecordResponse) GetResult() *DeleteResult { + if x != nil { + return x.Result + } + return nil +} + +// RenderResponse wraps this provider's rendered output. memory.md §10. +type RenderResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The rendered tree, per frontend.md §1. + Tree *v1.RenderTree `protobuf:"bytes,1,opt,name=tree,proto3" json:"tree,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenderResponse) Reset() { + *x = RenderResponse{} + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenderResponse) ProtoMessage() {} + +func (x *RenderResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[8] + 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 RenderResponse.ProtoReflect.Descriptor instead. +func (*RenderResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_response_proto_rawDescGZIP(), []int{8} +} + +func (x *RenderResponse) GetTree() *v1.RenderTree { + if x != nil { + return x.Tree + } + return nil +} + +// ListRecordsResponse carries one page of matching records. +type ListRecordsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // This page's records. + Records []*MemoryRecord `protobuf:"bytes,1,rep,name=records,proto3" json:"records,omitempty"` + // Opaque continuation token for the next page. Empty when this is the + // last page. + NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRecordsResponse) Reset() { + *x = ListRecordsResponse{} + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRecordsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRecordsResponse) ProtoMessage() {} + +func (x *ListRecordsResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[9] + 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 ListRecordsResponse.ProtoReflect.Descriptor instead. +func (*ListRecordsResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_response_proto_rawDescGZIP(), []int{9} +} + +func (x *ListRecordsResponse) GetRecords() []*MemoryRecord { + if x != nil { + return x.Records + } + return nil +} + +func (x *ListRecordsResponse) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + +// GetRecordResponse wraps the fetched record. +type GetRecordResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The fetched record. + Record *MemoryRecord `protobuf:"bytes,1,opt,name=record,proto3" json:"record,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetRecordResponse) Reset() { + *x = GetRecordResponse{} + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetRecordResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetRecordResponse) ProtoMessage() {} + +func (x *GetRecordResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[10] + 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 GetRecordResponse.ProtoReflect.Descriptor instead. +func (*GetRecordResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_response_proto_rawDescGZIP(), []int{10} +} + +func (x *GetRecordResponse) GetRecord() *MemoryRecord { + if x != nil { + return x.Record + } + return nil +} + +// DescribeResponse reports this plugin build's own identity. See the +// Describe RPC comment on MemoryService above. +type DescribeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // This plugin build's identity: name, version, source, category, + // protocol_version. + Producer *v11.ProducerRef `protobuf:"bytes,1,opt,name=producer,proto3" json:"producer,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeResponse) Reset() { + *x = DescribeResponse{} + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeResponse) ProtoMessage() {} + +func (x *DescribeResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_rpc_response_proto_msgTypes[11] + 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 DescribeResponse.ProtoReflect.Descriptor instead. +func (*DescribeResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_rpc_response_proto_rawDescGZIP(), []int{11} +} + +func (x *DescribeResponse) GetProducer() *v11.ProducerRef { + if x != nil { + return x.Producer + } + return nil +} + +var File_pluggableharness_memory_v1_rpc_response_proto protoreflect.FileDescriptor + +const file_pluggableharness_memory_v1_rpc_response_proto_rawDesc = "" + + "\n" + + "-pluggableharness/memory/v1/rpc_response.proto\x12\x1apluggableharness.memory.v1\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/memory/v1/types.proto\x1a&pluggableharness/render/v1/types.proto\"m\n" + + "\x17GetCapabilitiesResponse\x12R\n" + + "\fcapabilities\x18\x01 \x01(\v2..pluggableharness.memory.v1.MemoryCapabilitiesR\fcapabilities\"\x13\n" + + "\x11ConfigureResponse\"T\n" + + "\x0eRecallResponse\x12B\n" + + "\arecords\x18\x01 \x03(\v2(.pluggableharness.memory.v1.MemoryRecordR\arecords\"R\n" + + "\x0eRecordResponse\x12@\n" + + "\x06result\x18\x01 \x01(\v2(.pluggableharness.memory.v1.RecordResultR\x06result\"X\n" + + "\x14UpdateRecordResponse\x12@\n" + + "\x06result\x18\x01 \x01(\v2(.pluggableharness.memory.v1.RecordResultR\x06result\"X\n" + + "\x14DeleteRecordResponse\x12@\n" + + "\x06result\x18\x01 \x01(\v2(.pluggableharness.memory.v1.DeleteResultR\x06result\"Y\n" + + "\x15ApproveRecordResponse\x12@\n" + + "\x06result\x18\x01 \x01(\v2(.pluggableharness.memory.v1.RecordResultR\x06result\"X\n" + + "\x14RejectRecordResponse\x12@\n" + + "\x06result\x18\x01 \x01(\v2(.pluggableharness.memory.v1.DeleteResultR\x06result\"L\n" + + "\x0eRenderResponse\x12:\n" + + "\x04tree\x18\x01 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\x04tree\"\x81\x01\n" + + "\x13ListRecordsResponse\x12B\n" + + "\arecords\x18\x01 \x03(\v2(.pluggableharness.memory.v1.MemoryRecordR\arecords\x12&\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"U\n" + + "\x11GetRecordResponse\x12@\n" + + "\x06record\x18\x01 \x01(\v2(.pluggableharness.memory.v1.MemoryRecordR\x06record\"W\n" + + "\x10DescribeResponse\x12C\n" + + "\bproducer\x18\x01 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\bproducerB@Z>github.com/pluggableharness/agent/pkg/memory/proto/v1;memoryv1b\x06proto3" + +var ( + file_pluggableharness_memory_v1_rpc_response_proto_rawDescOnce sync.Once + file_pluggableharness_memory_v1_rpc_response_proto_rawDescData []byte +) + +func file_pluggableharness_memory_v1_rpc_response_proto_rawDescGZIP() []byte { + file_pluggableharness_memory_v1_rpc_response_proto_rawDescOnce.Do(func() { + file_pluggableharness_memory_v1_rpc_response_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_memory_v1_rpc_response_proto_rawDesc), len(file_pluggableharness_memory_v1_rpc_response_proto_rawDesc))) + }) + return file_pluggableharness_memory_v1_rpc_response_proto_rawDescData +} + +var file_pluggableharness_memory_v1_rpc_response_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_pluggableharness_memory_v1_rpc_response_proto_goTypes = []any{ + (*GetCapabilitiesResponse)(nil), // 0: pluggableharness.memory.v1.GetCapabilitiesResponse + (*ConfigureResponse)(nil), // 1: pluggableharness.memory.v1.ConfigureResponse + (*RecallResponse)(nil), // 2: pluggableharness.memory.v1.RecallResponse + (*RecordResponse)(nil), // 3: pluggableharness.memory.v1.RecordResponse + (*UpdateRecordResponse)(nil), // 4: pluggableharness.memory.v1.UpdateRecordResponse + (*DeleteRecordResponse)(nil), // 5: pluggableharness.memory.v1.DeleteRecordResponse + (*ApproveRecordResponse)(nil), // 6: pluggableharness.memory.v1.ApproveRecordResponse + (*RejectRecordResponse)(nil), // 7: pluggableharness.memory.v1.RejectRecordResponse + (*RenderResponse)(nil), // 8: pluggableharness.memory.v1.RenderResponse + (*ListRecordsResponse)(nil), // 9: pluggableharness.memory.v1.ListRecordsResponse + (*GetRecordResponse)(nil), // 10: pluggableharness.memory.v1.GetRecordResponse + (*DescribeResponse)(nil), // 11: pluggableharness.memory.v1.DescribeResponse + (*MemoryCapabilities)(nil), // 12: pluggableharness.memory.v1.MemoryCapabilities + (*MemoryRecord)(nil), // 13: pluggableharness.memory.v1.MemoryRecord + (*RecordResult)(nil), // 14: pluggableharness.memory.v1.RecordResult + (*DeleteResult)(nil), // 15: pluggableharness.memory.v1.DeleteResult + (*v1.RenderTree)(nil), // 16: pluggableharness.render.v1.RenderTree + (*v11.ProducerRef)(nil), // 17: pluggableharness.common.v1.ProducerRef +} +var file_pluggableharness_memory_v1_rpc_response_proto_depIdxs = []int32{ + 12, // 0: pluggableharness.memory.v1.GetCapabilitiesResponse.capabilities:type_name -> pluggableharness.memory.v1.MemoryCapabilities + 13, // 1: pluggableharness.memory.v1.RecallResponse.records:type_name -> pluggableharness.memory.v1.MemoryRecord + 14, // 2: pluggableharness.memory.v1.RecordResponse.result:type_name -> pluggableharness.memory.v1.RecordResult + 14, // 3: pluggableharness.memory.v1.UpdateRecordResponse.result:type_name -> pluggableharness.memory.v1.RecordResult + 15, // 4: pluggableharness.memory.v1.DeleteRecordResponse.result:type_name -> pluggableharness.memory.v1.DeleteResult + 14, // 5: pluggableharness.memory.v1.ApproveRecordResponse.result:type_name -> pluggableharness.memory.v1.RecordResult + 15, // 6: pluggableharness.memory.v1.RejectRecordResponse.result:type_name -> pluggableharness.memory.v1.DeleteResult + 16, // 7: pluggableharness.memory.v1.RenderResponse.tree:type_name -> pluggableharness.render.v1.RenderTree + 13, // 8: pluggableharness.memory.v1.ListRecordsResponse.records:type_name -> pluggableharness.memory.v1.MemoryRecord + 13, // 9: pluggableharness.memory.v1.GetRecordResponse.record:type_name -> pluggableharness.memory.v1.MemoryRecord + 17, // 10: pluggableharness.memory.v1.DescribeResponse.producer:type_name -> pluggableharness.common.v1.ProducerRef + 11, // [11:11] is the sub-list for method output_type + 11, // [11:11] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_pluggableharness_memory_v1_rpc_response_proto_init() } +func file_pluggableharness_memory_v1_rpc_response_proto_init() { + if File_pluggableharness_memory_v1_rpc_response_proto != nil { + return + } + file_pluggableharness_memory_v1_types_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_memory_v1_rpc_response_proto_rawDesc), len(file_pluggableharness_memory_v1_rpc_response_proto_rawDesc)), + NumEnums: 0, + NumMessages: 12, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_memory_v1_rpc_response_proto_goTypes, + DependencyIndexes: file_pluggableharness_memory_v1_rpc_response_proto_depIdxs, + MessageInfos: file_pluggableharness_memory_v1_rpc_response_proto_msgTypes, + }.Build() + File_pluggableharness_memory_v1_rpc_response_proto = out.File + file_pluggableharness_memory_v1_rpc_response_proto_goTypes = nil + file_pluggableharness_memory_v1_rpc_response_proto_depIdxs = nil +} diff --git a/pkg/memory/proto/v1/service.pb.go b/pkg/memory/proto/v1/service.pb.go new file mode 100644 index 0000000..0cfb83a --- /dev/null +++ b/pkg/memory/proto/v1/service.pb.go @@ -0,0 +1,133 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/memory/v1/service.proto + +// Package pluggableharness.memory.v1 defines the memory provider plugin protocol +// described in specifications/memory.md — plugins that persist knowledge +// across sessions (the write side) and recall it into future ones (the +// read side). A distinct plugin category with its own protocol, not a +// reuse of context.md's Contribute RPC, so record-specific data (type, +// scope, provenance, ratification status) stays first-class through a +// dedicated Recall RPC; the kernel adapts results into ContextSections +// before merging them into the assembled prompt (memory.md §6). + +package memoryv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_pluggableharness_memory_v1_service_proto protoreflect.FileDescriptor + +const file_pluggableharness_memory_v1_service_proto_rawDesc = "" + + "\n" + + "(pluggableharness/memory/v1/service.proto\x12\x1apluggableharness.memory.v1\x1a,pluggableharness/memory/v1/rpc_request.proto\x1a-pluggableharness/memory/v1/rpc_response.proto2\xa8\n" + + "\n" + + "\rMemoryService\x12z\n" + + "\x0fGetCapabilities\x122.pluggableharness.memory.v1.GetCapabilitiesRequest\x1a3.pluggableharness.memory.v1.GetCapabilitiesResponse\x12h\n" + + "\tConfigure\x12,.pluggableharness.memory.v1.ConfigureRequest\x1a-.pluggableharness.memory.v1.ConfigureResponse\x12_\n" + + "\x06Recall\x12).pluggableharness.memory.v1.RecallRequest\x1a*.pluggableharness.memory.v1.RecallResponse\x12_\n" + + "\x06Record\x12).pluggableharness.memory.v1.RecordRequest\x1a*.pluggableharness.memory.v1.RecordResponse\x12q\n" + + "\fUpdateRecord\x12/.pluggableharness.memory.v1.UpdateRecordRequest\x1a0.pluggableharness.memory.v1.UpdateRecordResponse\x12q\n" + + "\fDeleteRecord\x12/.pluggableharness.memory.v1.DeleteRecordRequest\x1a0.pluggableharness.memory.v1.DeleteRecordResponse\x12t\n" + + "\rApproveRecord\x120.pluggableharness.memory.v1.ApproveRecordRequest\x1a1.pluggableharness.memory.v1.ApproveRecordResponse\x12q\n" + + "\fRejectRecord\x12/.pluggableharness.memory.v1.RejectRecordRequest\x1a0.pluggableharness.memory.v1.RejectRecordResponse\x12_\n" + + "\x06Render\x12).pluggableharness.memory.v1.RenderRequest\x1a*.pluggableharness.memory.v1.RenderResponse\x12n\n" + + "\vListRecords\x12..pluggableharness.memory.v1.ListRecordsRequest\x1a/.pluggableharness.memory.v1.ListRecordsResponse\x12h\n" + + "\tGetRecord\x12,.pluggableharness.memory.v1.GetRecordRequest\x1a-.pluggableharness.memory.v1.GetRecordResponse\x12e\n" + + "\bDescribe\x12+.pluggableharness.memory.v1.DescribeRequest\x1a,.pluggableharness.memory.v1.DescribeResponseB@Z>github.com/pluggableharness/agent/pkg/memory/proto/v1;memoryv1b\x06proto3" + +var file_pluggableharness_memory_v1_service_proto_goTypes = []any{ + (*GetCapabilitiesRequest)(nil), // 0: pluggableharness.memory.v1.GetCapabilitiesRequest + (*ConfigureRequest)(nil), // 1: pluggableharness.memory.v1.ConfigureRequest + (*RecallRequest)(nil), // 2: pluggableharness.memory.v1.RecallRequest + (*RecordRequest)(nil), // 3: pluggableharness.memory.v1.RecordRequest + (*UpdateRecordRequest)(nil), // 4: pluggableharness.memory.v1.UpdateRecordRequest + (*DeleteRecordRequest)(nil), // 5: pluggableharness.memory.v1.DeleteRecordRequest + (*ApproveRecordRequest)(nil), // 6: pluggableharness.memory.v1.ApproveRecordRequest + (*RejectRecordRequest)(nil), // 7: pluggableharness.memory.v1.RejectRecordRequest + (*RenderRequest)(nil), // 8: pluggableharness.memory.v1.RenderRequest + (*ListRecordsRequest)(nil), // 9: pluggableharness.memory.v1.ListRecordsRequest + (*GetRecordRequest)(nil), // 10: pluggableharness.memory.v1.GetRecordRequest + (*DescribeRequest)(nil), // 11: pluggableharness.memory.v1.DescribeRequest + (*GetCapabilitiesResponse)(nil), // 12: pluggableharness.memory.v1.GetCapabilitiesResponse + (*ConfigureResponse)(nil), // 13: pluggableharness.memory.v1.ConfigureResponse + (*RecallResponse)(nil), // 14: pluggableharness.memory.v1.RecallResponse + (*RecordResponse)(nil), // 15: pluggableharness.memory.v1.RecordResponse + (*UpdateRecordResponse)(nil), // 16: pluggableharness.memory.v1.UpdateRecordResponse + (*DeleteRecordResponse)(nil), // 17: pluggableharness.memory.v1.DeleteRecordResponse + (*ApproveRecordResponse)(nil), // 18: pluggableharness.memory.v1.ApproveRecordResponse + (*RejectRecordResponse)(nil), // 19: pluggableharness.memory.v1.RejectRecordResponse + (*RenderResponse)(nil), // 20: pluggableharness.memory.v1.RenderResponse + (*ListRecordsResponse)(nil), // 21: pluggableharness.memory.v1.ListRecordsResponse + (*GetRecordResponse)(nil), // 22: pluggableharness.memory.v1.GetRecordResponse + (*DescribeResponse)(nil), // 23: pluggableharness.memory.v1.DescribeResponse +} +var file_pluggableharness_memory_v1_service_proto_depIdxs = []int32{ + 0, // 0: pluggableharness.memory.v1.MemoryService.GetCapabilities:input_type -> pluggableharness.memory.v1.GetCapabilitiesRequest + 1, // 1: pluggableharness.memory.v1.MemoryService.Configure:input_type -> pluggableharness.memory.v1.ConfigureRequest + 2, // 2: pluggableharness.memory.v1.MemoryService.Recall:input_type -> pluggableharness.memory.v1.RecallRequest + 3, // 3: pluggableharness.memory.v1.MemoryService.Record:input_type -> pluggableharness.memory.v1.RecordRequest + 4, // 4: pluggableharness.memory.v1.MemoryService.UpdateRecord:input_type -> pluggableharness.memory.v1.UpdateRecordRequest + 5, // 5: pluggableharness.memory.v1.MemoryService.DeleteRecord:input_type -> pluggableharness.memory.v1.DeleteRecordRequest + 6, // 6: pluggableharness.memory.v1.MemoryService.ApproveRecord:input_type -> pluggableharness.memory.v1.ApproveRecordRequest + 7, // 7: pluggableharness.memory.v1.MemoryService.RejectRecord:input_type -> pluggableharness.memory.v1.RejectRecordRequest + 8, // 8: pluggableharness.memory.v1.MemoryService.Render:input_type -> pluggableharness.memory.v1.RenderRequest + 9, // 9: pluggableharness.memory.v1.MemoryService.ListRecords:input_type -> pluggableharness.memory.v1.ListRecordsRequest + 10, // 10: pluggableharness.memory.v1.MemoryService.GetRecord:input_type -> pluggableharness.memory.v1.GetRecordRequest + 11, // 11: pluggableharness.memory.v1.MemoryService.Describe:input_type -> pluggableharness.memory.v1.DescribeRequest + 12, // 12: pluggableharness.memory.v1.MemoryService.GetCapabilities:output_type -> pluggableharness.memory.v1.GetCapabilitiesResponse + 13, // 13: pluggableharness.memory.v1.MemoryService.Configure:output_type -> pluggableharness.memory.v1.ConfigureResponse + 14, // 14: pluggableharness.memory.v1.MemoryService.Recall:output_type -> pluggableharness.memory.v1.RecallResponse + 15, // 15: pluggableharness.memory.v1.MemoryService.Record:output_type -> pluggableharness.memory.v1.RecordResponse + 16, // 16: pluggableharness.memory.v1.MemoryService.UpdateRecord:output_type -> pluggableharness.memory.v1.UpdateRecordResponse + 17, // 17: pluggableharness.memory.v1.MemoryService.DeleteRecord:output_type -> pluggableharness.memory.v1.DeleteRecordResponse + 18, // 18: pluggableharness.memory.v1.MemoryService.ApproveRecord:output_type -> pluggableharness.memory.v1.ApproveRecordResponse + 19, // 19: pluggableharness.memory.v1.MemoryService.RejectRecord:output_type -> pluggableharness.memory.v1.RejectRecordResponse + 20, // 20: pluggableharness.memory.v1.MemoryService.Render:output_type -> pluggableharness.memory.v1.RenderResponse + 21, // 21: pluggableharness.memory.v1.MemoryService.ListRecords:output_type -> pluggableharness.memory.v1.ListRecordsResponse + 22, // 22: pluggableharness.memory.v1.MemoryService.GetRecord:output_type -> pluggableharness.memory.v1.GetRecordResponse + 23, // 23: pluggableharness.memory.v1.MemoryService.Describe:output_type -> pluggableharness.memory.v1.DescribeResponse + 12, // [12:24] is the sub-list for method output_type + 0, // [0:12] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_pluggableharness_memory_v1_service_proto_init() } +func file_pluggableharness_memory_v1_service_proto_init() { + if File_pluggableharness_memory_v1_service_proto != nil { + return + } + file_pluggableharness_memory_v1_rpc_request_proto_init() + file_pluggableharness_memory_v1_rpc_response_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_memory_v1_service_proto_rawDesc), len(file_pluggableharness_memory_v1_service_proto_rawDesc)), + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_pluggableharness_memory_v1_service_proto_goTypes, + DependencyIndexes: file_pluggableharness_memory_v1_service_proto_depIdxs, + }.Build() + File_pluggableharness_memory_v1_service_proto = out.File + file_pluggableharness_memory_v1_service_proto_goTypes = nil + file_pluggableharness_memory_v1_service_proto_depIdxs = nil +} diff --git a/pkg/memory/proto/v1/memory_grpc.pb.go b/pkg/memory/proto/v1/service_grpc.pb.go similarity index 99% rename from pkg/memory/proto/v1/memory_grpc.pb.go rename to pkg/memory/proto/v1/service_grpc.pb.go index 8252e42..a2882e5 100644 --- a/pkg/memory/proto/v1/memory_grpc.pb.go +++ b/pkg/memory/proto/v1/service_grpc.pb.go @@ -2,7 +2,7 @@ // versions: // - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) -// source: pluggableharness/memory/v1/memory.proto +// source: pluggableharness/memory/v1/service.proto // Package pluggableharness.memory.v1 defines the memory provider plugin protocol // described in specifications/memory.md — plugins that persist knowledge @@ -636,5 +636,5 @@ var MemoryService_ServiceDesc = grpc.ServiceDesc{ }, }, Streams: []grpc.StreamDesc{}, - Metadata: "pluggableharness/memory/v1/memory.proto", + Metadata: "pluggableharness/memory/v1/service.proto", } diff --git a/pkg/memory/proto/v1/types.pb.go b/pkg/memory/proto/v1/types.pb.go new file mode 100644 index 0000000..b8ef998 --- /dev/null +++ b/pkg/memory/proto/v1/types.pb.go @@ -0,0 +1,815 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/memory/v1/types.proto + +package memoryv1 + +import ( + v1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + v11 "github.com/pluggableharness/agent/pkg/config/proto/v1" + v12 "github.com/pluggableharness/agent/pkg/content/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// MemoryType is the record taxonomy, fixed at the protocol level rather +// than provider-defined (memory.md §4). A record MUST declare exactly one +// MemoryType, and it is immutable after creation — recategorizing means +// DeleteRecord followed by a new Record call, not UpdateRecord. +type MemoryType int32 + +const ( + // Zero value. Never valid for a real record; its presence on the wire + // means a caller forgot to set the field. + MemoryType_MEMORY_TYPE_UNSPECIFIED MemoryType = 0 + // The subject's role, goals, responsibilities, and knowledge. Tailors + // future behavior to who they are and what they already know. + MemoryType_MEMORY_TYPE_USER MemoryType = 1 + // Guidance on how to approach work, captured from both corrections + // ("stop doing X") and confirmations ("yes, keep doing that") — both + // directions matter equally. + MemoryType_MEMORY_TYPE_FEEDBACK MemoryType = 2 + // Ongoing work, goals, decisions, and incidents not otherwise derivable + // from code or git history. Decays faster than the other three types; a + // provider SHOULD weight recency more heavily for this type. + MemoryType_MEMORY_TYPE_PROJECT MemoryType = 3 + // Pointers to where information lives in external systems (an issue + // tracker, a dashboard, a channel) — not the information itself. + MemoryType_MEMORY_TYPE_REFERENCE MemoryType = 4 +) + +// Enum value maps for MemoryType. +var ( + MemoryType_name = map[int32]string{ + 0: "MEMORY_TYPE_UNSPECIFIED", + 1: "MEMORY_TYPE_USER", + 2: "MEMORY_TYPE_FEEDBACK", + 3: "MEMORY_TYPE_PROJECT", + 4: "MEMORY_TYPE_REFERENCE", + } + MemoryType_value = map[string]int32{ + "MEMORY_TYPE_UNSPECIFIED": 0, + "MEMORY_TYPE_USER": 1, + "MEMORY_TYPE_FEEDBACK": 2, + "MEMORY_TYPE_PROJECT": 3, + "MEMORY_TYPE_REFERENCE": 4, + } +) + +func (x MemoryType) Enum() *MemoryType { + p := new(MemoryType) + *p = x + return p +} + +func (x MemoryType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (MemoryType) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_memory_v1_types_proto_enumTypes[0].Descriptor() +} + +func (MemoryType) Type() protoreflect.EnumType { + return &file_pluggableharness_memory_v1_types_proto_enumTypes[0] +} + +func (x MemoryType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use MemoryType.Descriptor instead. +func (MemoryType) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_types_proto_rawDescGZIP(), []int{0} +} + +// MemoryScope is the visibility taxonomy a record declares, fixed at the +// protocol level (memory.md §4.1). Immutable per record once set, same as +// MemoryType. +type MemoryScope int32 + +const ( + // Zero value. Never valid for a real record; its presence on the wire + // means a caller forgot to set the field. + MemoryScope_MEMORY_SCOPE_UNSPECIFIED MemoryScope = 0 + // Visible only within the session (and its descendants) that wrote it — + // not recalled by unrelated future sessions, though still durably + // logged to the state backend for audit. + MemoryScope_MEMORY_SCOPE_SESSION MemoryScope = 1 + // Scoped to the current working directory/project, recalled by any + // session operating in that project. + MemoryScope_MEMORY_SCOPE_PROJECT MemoryScope = 2 + // Recalled across every project, mirroring a memory system that spans + // all of a subject's work. + MemoryScope_MEMORY_SCOPE_GLOBAL MemoryScope = 3 +) + +// Enum value maps for MemoryScope. +var ( + MemoryScope_name = map[int32]string{ + 0: "MEMORY_SCOPE_UNSPECIFIED", + 1: "MEMORY_SCOPE_SESSION", + 2: "MEMORY_SCOPE_PROJECT", + 3: "MEMORY_SCOPE_GLOBAL", + } + MemoryScope_value = map[string]int32{ + "MEMORY_SCOPE_UNSPECIFIED": 0, + "MEMORY_SCOPE_SESSION": 1, + "MEMORY_SCOPE_PROJECT": 2, + "MEMORY_SCOPE_GLOBAL": 3, + } +) + +func (x MemoryScope) Enum() *MemoryScope { + p := new(MemoryScope) + *p = x + return p +} + +func (x MemoryScope) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (MemoryScope) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_memory_v1_types_proto_enumTypes[1].Descriptor() +} + +func (MemoryScope) Type() protoreflect.EnumType { + return &file_pluggableharness_memory_v1_types_proto_enumTypes[1] +} + +func (x MemoryScope) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use MemoryScope.Descriptor instead. +func (MemoryScope) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_types_proto_rawDescGZIP(), []int{1} +} + +// RecordStatus distinguishes a fully-persisted record from one awaiting +// review under the optional ratification pattern (memory.md §8). +type RecordStatus int32 + +const ( + // Zero value. Never valid for a real record; its presence on the wire + // means a caller forgot to set the field. + RecordStatus_RECORD_STATUS_UNSPECIFIED RecordStatus = 0 + // The record is part of what Recall normally surfaces. + RecordStatus_RECORD_STATUS_CANONICAL RecordStatus = 1 + // The record is a drafted-but-not-yet-reviewed write. A provider with + // ratification_supported == false MUST NEVER return this status + // (memory.md §8). + RecordStatus_RECORD_STATUS_PENDING RecordStatus = 2 +) + +// Enum value maps for RecordStatus. +var ( + RecordStatus_name = map[int32]string{ + 0: "RECORD_STATUS_UNSPECIFIED", + 1: "RECORD_STATUS_CANONICAL", + 2: "RECORD_STATUS_PENDING", + } + RecordStatus_value = map[string]int32{ + "RECORD_STATUS_UNSPECIFIED": 0, + "RECORD_STATUS_CANONICAL": 1, + "RECORD_STATUS_PENDING": 2, + } +) + +func (x RecordStatus) Enum() *RecordStatus { + p := new(RecordStatus) + *p = x + return p +} + +func (x RecordStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (RecordStatus) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_memory_v1_types_proto_enumTypes[2].Descriptor() +} + +func (RecordStatus) Type() protoreflect.EnumType { + return &file_pluggableharness_memory_v1_types_proto_enumTypes[2] +} + +func (x RecordStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use RecordStatus.Descriptor instead. +func (RecordStatus) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_types_proto_rawDescGZIP(), []int{2} +} + +// MemoryCapabilities is this provider's capability advertisement, returned +// by GetCapabilities. memory.md §3. +type MemoryCapabilities struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The default token budget this provider requests for its Recall + // contributions, absent any override — same convention as context.md + // §6's reserved token_budget config field. MUST be set. + DefaultTokenBudget int64 `protobuf:"varint,1,opt,name=default_token_budget,json=defaultTokenBudget,proto3" json:"default_token_budget,omitempty"` + // Which MemoryTypes this provider handles. MUST be set; MAY be a subset + // of the full MemoryType enum. + SupportedTypes []MemoryType `protobuf:"varint,2,rep,packed,name=supported_types,json=supportedTypes,proto3,enum=pluggableharness.memory.v1.MemoryType" json:"supported_types,omitempty"` + // Which MemoryScopes this provider handles. MUST be set; MAY be a + // subset of the full MemoryScope enum (e.g. project-only). + SupportedScopes []MemoryScope `protobuf:"varint,3,rep,packed,name=supported_scopes,json=supportedScopes,proto3,enum=pluggableharness.memory.v1.MemoryScope" json:"supported_scopes,omitempty"` + // Whether this provider implements the ApproveRecord/RejectRecord + // ratification pattern (memory.md §8). MUST be set; defaults to false. + RatificationSupported bool `protobuf:"varint,4,opt,name=ratification_supported,json=ratificationSupported,proto3" json:"ratification_supported,omitempty"` + // Prompt-expansion slash commands this provider contributes, per + // frontend.md §5. MAY be empty — the reference tools (memory.md §9.2) + // already cover the common remember/forget/search cases via the + // ordinary tool-provider path. A direct-invoke command is declared by + // a slashcommand.v1 provider instead (specifications/slashcommand/), + // never here. + SlashCommands []*v1.PromptExpansionSpec `protobuf:"bytes,5,rep,name=slash_commands,json=slashCommands,proto3" json:"slash_commands,omitempty"` + // This provider's agent.hcl config schema, per configuration.md §4. + ConfigSchema *v11.ConfigSchema `protobuf:"bytes,6,opt,name=config_schema,json=configSchema,proto3" json:"config_schema,omitempty"` + // Which hook points (agent-loop/hook-dispatch.md) this provider declares + // HookSubscriberService.DispatchHook subscriptions for. Note: this + // category's implicit post_model_response (observe, every turn) and + // session_end (fires once) write triggers (memory.md §Write triggers) + // now ride the hook.v1 HookSubscriberService.DispatchHook surface — see + // protocol.md's Write triggers section for the payload shapes. The + // HookPoint enum itself lives in common.v1, not hook.v1 — hook.v1 + // imports this package's model/tool/plan dependencies, so a category + // capability message importing hook.v1 directly would cycle back + // through it; common.v1 is the shared leaf package instead. + SupportedHookPoints []v1.HookPoint `protobuf:"varint,7,rep,packed,name=supported_hook_points,json=supportedHookPoints,proto3,enum=pluggableharness.common.v1.HookPoint" json:"supported_hook_points,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryCapabilities) Reset() { + *x = MemoryCapabilities{} + mi := &file_pluggableharness_memory_v1_types_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryCapabilities) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryCapabilities) ProtoMessage() {} + +func (x *MemoryCapabilities) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_types_proto_msgTypes[0] + 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 MemoryCapabilities.ProtoReflect.Descriptor instead. +func (*MemoryCapabilities) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_types_proto_rawDescGZIP(), []int{0} +} + +func (x *MemoryCapabilities) GetDefaultTokenBudget() int64 { + if x != nil { + return x.DefaultTokenBudget + } + return 0 +} + +func (x *MemoryCapabilities) GetSupportedTypes() []MemoryType { + if x != nil { + return x.SupportedTypes + } + return nil +} + +func (x *MemoryCapabilities) GetSupportedScopes() []MemoryScope { + if x != nil { + return x.SupportedScopes + } + return nil +} + +func (x *MemoryCapabilities) GetRatificationSupported() bool { + if x != nil { + return x.RatificationSupported + } + return false +} + +func (x *MemoryCapabilities) GetSlashCommands() []*v1.PromptExpansionSpec { + if x != nil { + return x.SlashCommands + } + return nil +} + +func (x *MemoryCapabilities) GetConfigSchema() *v11.ConfigSchema { + if x != nil { + return x.ConfigSchema + } + return nil +} + +func (x *MemoryCapabilities) GetSupportedHookPoints() []v1.HookPoint { + if x != nil { + return x.SupportedHookPoints + } + return nil +} + +// MemoryRecord is one persisted unit of memory. memory.md §6. +type MemoryRecord struct { + state protoimpl.MessageState `protogen:"open.v1"` + // A slug, unique within this provider. Kernel-enforced uniqueness per + // memory.md §7. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // This record's fixed taxonomy classification. Immutable after + // creation. + Type MemoryType `protobuf:"varint,2,opt,name=type,proto3,enum=pluggableharness.memory.v1.MemoryType" json:"type,omitempty"` + // This record's visibility scope. MUST be set; immutable after + // creation, like `type`. + Scope MemoryScope `protobuf:"varint,3,opt,name=scope,proto3,enum=pluggableharness.memory.v1.MemoryScope" json:"scope,omitempty"` + // Human-readable title. + Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` + // The record's content. Text-only in v1, same constraint as context.md + // §4. + Content []*v12.ContentBlock `protobuf:"bytes,5,rep,name=content,proto3" json:"content,omitempty"` + // This record's size, computed via the kernel's CountTokens callback + // (kernel-callbacks.md §2), never a provider-local heuristic. + Tokens int64 `protobuf:"varint,6,opt,name=tokens,proto3" json:"tokens,omitempty"` + // Whether this record is fully persisted or awaiting ratification. + Status RecordStatus `protobuf:"varint,7,opt,name=status,proto3,enum=pluggableharness.memory.v1.RecordStatus" json:"status,omitempty"` + // Record ids this record references. MUST be set — kernel-parsed from + // "[[name]]" syntax in `content` at Record/UpdateRecord time + // (memory.md §7.1), not provider-populated. + Links []string `protobuf:"bytes,8,rep,name=links,proto3" json:"links,omitempty"` + // When this record was first created. + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + // When this record was last modified. + UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + // Where this record came from and who wrote it. Kernel-populated at + // Record time, immutable thereafter — never provider-supplied or + // provider-mutable. memory.md's README already frames provenance as + // first-class; this is where the record shape finally carries it. + Provenance *Provenance `protobuf:"bytes,11,opt,name=provenance,proto3" json:"provenance,omitempty"` + // This record's recall-time relevance, in [0, 1]. Set only on + // Recall/ListRecords responses — never persisted, never present on a + // Record/UpdateRecord request or response. Lets the kernel merge + // multiple memory providers' results under one shared budget + // (data-types.md#recallrequest--memoryrecord) using a comparable score + // rather than provider-opaque ordering alone. A provider setting this + // MUST normalize it to [0, 1]; scores from different providers are only + // meaningfully comparable if each normalizes to the same range. + RelevanceScore *float64 `protobuf:"fixed64,12,opt,name=relevance_score,json=relevanceScore,proto3,oneof" json:"relevance_score,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryRecord) Reset() { + *x = MemoryRecord{} + mi := &file_pluggableharness_memory_v1_types_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryRecord) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryRecord) ProtoMessage() {} + +func (x *MemoryRecord) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_types_proto_msgTypes[1] + 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 MemoryRecord.ProtoReflect.Descriptor instead. +func (*MemoryRecord) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_types_proto_rawDescGZIP(), []int{1} +} + +func (x *MemoryRecord) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *MemoryRecord) GetType() MemoryType { + if x != nil { + return x.Type + } + return MemoryType_MEMORY_TYPE_UNSPECIFIED +} + +func (x *MemoryRecord) GetScope() MemoryScope { + if x != nil { + return x.Scope + } + return MemoryScope_MEMORY_SCOPE_UNSPECIFIED +} + +func (x *MemoryRecord) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *MemoryRecord) GetContent() []*v12.ContentBlock { + if x != nil { + return x.Content + } + return nil +} + +func (x *MemoryRecord) GetTokens() int64 { + if x != nil { + return x.Tokens + } + return 0 +} + +func (x *MemoryRecord) GetStatus() RecordStatus { + if x != nil { + return x.Status + } + return RecordStatus_RECORD_STATUS_UNSPECIFIED +} + +func (x *MemoryRecord) GetLinks() []string { + if x != nil { + return x.Links + } + return nil +} + +func (x *MemoryRecord) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *MemoryRecord) GetUpdatedAt() *timestamppb.Timestamp { + if x != nil { + return x.UpdatedAt + } + return nil +} + +func (x *MemoryRecord) GetProvenance() *Provenance { + if x != nil { + return x.Provenance + } + return nil +} + +func (x *MemoryRecord) GetRelevanceScore() float64 { + if x != nil && x.RelevanceScore != nil { + return *x.RelevanceScore + } + return 0 +} + +// Provenance records where a MemoryRecord came from and who wrote it. +// Kernel-populated at Record time; immutable afterward, like the rest of a +// record's write-time metadata. +type Provenance struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session id that produced this record. + SourceSessionId string `protobuf:"bytes,1,opt,name=source_session_id,json=sourceSessionId,proto3" json:"source_session_id,omitempty"` + // The turn id (ULID, see ContextRequest.turn_id) within source_session_id + // that produced this record, when known. Absent for a record written + // outside normal turn flow (e.g. a backfill/import). + SourceTurnId *string `protobuf:"bytes,2,opt,name=source_turn_id,json=sourceTurnId,proto3,oneof" json:"source_turn_id,omitempty"` + // The producing plugin's declared name, or the reference tool path that + // wrote it (e.g. "memory.remember"). Kernel-populated from the calling + // context, not provider-supplied. + RecordedBy string `protobuf:"bytes,3,opt,name=recorded_by,json=recordedBy,proto3" json:"recorded_by,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Provenance) Reset() { + *x = Provenance{} + mi := &file_pluggableharness_memory_v1_types_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Provenance) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Provenance) ProtoMessage() {} + +func (x *Provenance) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_types_proto_msgTypes[2] + 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 Provenance.ProtoReflect.Descriptor instead. +func (*Provenance) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_types_proto_rawDescGZIP(), []int{2} +} + +func (x *Provenance) GetSourceSessionId() string { + if x != nil { + return x.SourceSessionId + } + return "" +} + +func (x *Provenance) GetSourceTurnId() string { + if x != nil && x.SourceTurnId != nil { + return *x.SourceTurnId + } + return "" +} + +func (x *Provenance) GetRecordedBy() string { + if x != nil { + return x.RecordedBy + } + return "" +} + +// RecordResult is the shared outcome shape for Record (via +// RecordResponse), UpdateRecord (via UpdateRecordResponse), and +// ApproveRecord (via ApproveRecordResponse) — a reusable domain type, not +// itself an RPC response type for more than one RPC. +type RecordResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The final assigned slug. MUST be set. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Whether the write is fully persisted or awaiting ratification. + Status RecordStatus `protobuf:"varint,2,opt,name=status,proto3,enum=pluggableharness.memory.v1.RecordStatus" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecordResult) Reset() { + *x = RecordResult{} + mi := &file_pluggableharness_memory_v1_types_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecordResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecordResult) ProtoMessage() {} + +func (x *RecordResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_types_proto_msgTypes[3] + 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 RecordResult.ProtoReflect.Descriptor instead. +func (*RecordResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_types_proto_rawDescGZIP(), []int{3} +} + +func (x *RecordResult) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *RecordResult) GetStatus() RecordStatus { + if x != nil { + return x.Status + } + return RecordStatus_RECORD_STATUS_UNSPECIFIED +} + +// DeleteResult is the shared outcome shape for DeleteRecord (via +// DeleteRecordResponse) and RejectRecord (via RejectRecordResponse) — a +// reusable domain type, not itself an RPC response type for more than one +// RPC. +type DeleteResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // True if a record was actually removed. + Deleted bool `protobuf:"varint,1,opt,name=deleted,proto3" json:"deleted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteResult) Reset() { + *x = DeleteResult{} + mi := &file_pluggableharness_memory_v1_types_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteResult) ProtoMessage() {} + +func (x *DeleteResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_memory_v1_types_proto_msgTypes[4] + 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 DeleteResult.ProtoReflect.Descriptor instead. +func (*DeleteResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_memory_v1_types_proto_rawDescGZIP(), []int{4} +} + +func (x *DeleteResult) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +var File_pluggableharness_memory_v1_types_proto protoreflect.FileDescriptor + +const file_pluggableharness_memory_v1_types_proto_rawDesc = "" + + "\n" + + "&pluggableharness/memory/v1/types.proto\x12\x1apluggableharness.memory.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/config/v1/types.proto\x1a'pluggableharness/content/v1/types.proto\"\xa4\x04\n" + + "\x12MemoryCapabilities\x120\n" + + "\x14default_token_budget\x18\x01 \x01(\x03R\x12defaultTokenBudget\x12O\n" + + "\x0fsupported_types\x18\x02 \x03(\x0e2&.pluggableharness.memory.v1.MemoryTypeR\x0esupportedTypes\x12R\n" + + "\x10supported_scopes\x18\x03 \x03(\x0e2'.pluggableharness.memory.v1.MemoryScopeR\x0fsupportedScopes\x125\n" + + "\x16ratification_supported\x18\x04 \x01(\bR\x15ratificationSupported\x12V\n" + + "\x0eslash_commands\x18\x05 \x03(\v2/.pluggableharness.common.v1.PromptExpansionSpecR\rslashCommands\x12M\n" + + "\rconfig_schema\x18\x06 \x01(\v2(.pluggableharness.config.v1.ConfigSchemaR\fconfigSchema\x12Y\n" + + "\x15supported_hook_points\x18\a \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPoints\"\xe4\x04\n" + + "\fMemoryRecord\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12:\n" + + "\x04type\x18\x02 \x01(\x0e2&.pluggableharness.memory.v1.MemoryTypeR\x04type\x12=\n" + + "\x05scope\x18\x03 \x01(\x0e2'.pluggableharness.memory.v1.MemoryScopeR\x05scope\x12\x14\n" + + "\x05title\x18\x04 \x01(\tR\x05title\x12C\n" + + "\acontent\x18\x05 \x03(\v2).pluggableharness.content.v1.ContentBlockR\acontent\x12\x16\n" + + "\x06tokens\x18\x06 \x01(\x03R\x06tokens\x12@\n" + + "\x06status\x18\a \x01(\x0e2(.pluggableharness.memory.v1.RecordStatusR\x06status\x12\x14\n" + + "\x05links\x18\b \x03(\tR\x05links\x129\n" + + "\n" + + "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + + "\n" + + "updated_at\x18\n" + + " \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12F\n" + + "\n" + + "provenance\x18\v \x01(\v2&.pluggableharness.memory.v1.ProvenanceR\n" + + "provenance\x12,\n" + + "\x0frelevance_score\x18\f \x01(\x01H\x00R\x0erelevanceScore\x88\x01\x01B\x12\n" + + "\x10_relevance_score\"\x97\x01\n" + + "\n" + + "Provenance\x12*\n" + + "\x11source_session_id\x18\x01 \x01(\tR\x0fsourceSessionId\x12)\n" + + "\x0esource_turn_id\x18\x02 \x01(\tH\x00R\fsourceTurnId\x88\x01\x01\x12\x1f\n" + + "\vrecorded_by\x18\x03 \x01(\tR\n" + + "recordedByB\x11\n" + + "\x0f_source_turn_id\"`\n" + + "\fRecordResult\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12@\n" + + "\x06status\x18\x02 \x01(\x0e2(.pluggableharness.memory.v1.RecordStatusR\x06status\"(\n" + + "\fDeleteResult\x12\x18\n" + + "\adeleted\x18\x01 \x01(\bR\adeleted*\x8d\x01\n" + + "\n" + + "MemoryType\x12\x1b\n" + + "\x17MEMORY_TYPE_UNSPECIFIED\x10\x00\x12\x14\n" + + "\x10MEMORY_TYPE_USER\x10\x01\x12\x18\n" + + "\x14MEMORY_TYPE_FEEDBACK\x10\x02\x12\x17\n" + + "\x13MEMORY_TYPE_PROJECT\x10\x03\x12\x19\n" + + "\x15MEMORY_TYPE_REFERENCE\x10\x04*x\n" + + "\vMemoryScope\x12\x1c\n" + + "\x18MEMORY_SCOPE_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14MEMORY_SCOPE_SESSION\x10\x01\x12\x18\n" + + "\x14MEMORY_SCOPE_PROJECT\x10\x02\x12\x17\n" + + "\x13MEMORY_SCOPE_GLOBAL\x10\x03*e\n" + + "\fRecordStatus\x12\x1d\n" + + "\x19RECORD_STATUS_UNSPECIFIED\x10\x00\x12\x1b\n" + + "\x17RECORD_STATUS_CANONICAL\x10\x01\x12\x19\n" + + "\x15RECORD_STATUS_PENDING\x10\x02B@Z>github.com/pluggableharness/agent/pkg/memory/proto/v1;memoryv1b\x06proto3" + +var ( + file_pluggableharness_memory_v1_types_proto_rawDescOnce sync.Once + file_pluggableharness_memory_v1_types_proto_rawDescData []byte +) + +func file_pluggableharness_memory_v1_types_proto_rawDescGZIP() []byte { + file_pluggableharness_memory_v1_types_proto_rawDescOnce.Do(func() { + file_pluggableharness_memory_v1_types_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_memory_v1_types_proto_rawDesc), len(file_pluggableharness_memory_v1_types_proto_rawDesc))) + }) + return file_pluggableharness_memory_v1_types_proto_rawDescData +} + +var file_pluggableharness_memory_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_pluggableharness_memory_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_pluggableharness_memory_v1_types_proto_goTypes = []any{ + (MemoryType)(0), // 0: pluggableharness.memory.v1.MemoryType + (MemoryScope)(0), // 1: pluggableharness.memory.v1.MemoryScope + (RecordStatus)(0), // 2: pluggableharness.memory.v1.RecordStatus + (*MemoryCapabilities)(nil), // 3: pluggableharness.memory.v1.MemoryCapabilities + (*MemoryRecord)(nil), // 4: pluggableharness.memory.v1.MemoryRecord + (*Provenance)(nil), // 5: pluggableharness.memory.v1.Provenance + (*RecordResult)(nil), // 6: pluggableharness.memory.v1.RecordResult + (*DeleteResult)(nil), // 7: pluggableharness.memory.v1.DeleteResult + (*v1.PromptExpansionSpec)(nil), // 8: pluggableharness.common.v1.PromptExpansionSpec + (*v11.ConfigSchema)(nil), // 9: pluggableharness.config.v1.ConfigSchema + (v1.HookPoint)(0), // 10: pluggableharness.common.v1.HookPoint + (*v12.ContentBlock)(nil), // 11: pluggableharness.content.v1.ContentBlock + (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp +} +var file_pluggableharness_memory_v1_types_proto_depIdxs = []int32{ + 0, // 0: pluggableharness.memory.v1.MemoryCapabilities.supported_types:type_name -> pluggableharness.memory.v1.MemoryType + 1, // 1: pluggableharness.memory.v1.MemoryCapabilities.supported_scopes:type_name -> pluggableharness.memory.v1.MemoryScope + 8, // 2: pluggableharness.memory.v1.MemoryCapabilities.slash_commands:type_name -> pluggableharness.common.v1.PromptExpansionSpec + 9, // 3: pluggableharness.memory.v1.MemoryCapabilities.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema + 10, // 4: pluggableharness.memory.v1.MemoryCapabilities.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint + 0, // 5: pluggableharness.memory.v1.MemoryRecord.type:type_name -> pluggableharness.memory.v1.MemoryType + 1, // 6: pluggableharness.memory.v1.MemoryRecord.scope:type_name -> pluggableharness.memory.v1.MemoryScope + 11, // 7: pluggableharness.memory.v1.MemoryRecord.content:type_name -> pluggableharness.content.v1.ContentBlock + 2, // 8: pluggableharness.memory.v1.MemoryRecord.status:type_name -> pluggableharness.memory.v1.RecordStatus + 12, // 9: pluggableharness.memory.v1.MemoryRecord.created_at:type_name -> google.protobuf.Timestamp + 12, // 10: pluggableharness.memory.v1.MemoryRecord.updated_at:type_name -> google.protobuf.Timestamp + 5, // 11: pluggableharness.memory.v1.MemoryRecord.provenance:type_name -> pluggableharness.memory.v1.Provenance + 2, // 12: pluggableharness.memory.v1.RecordResult.status:type_name -> pluggableharness.memory.v1.RecordStatus + 13, // [13:13] is the sub-list for method output_type + 13, // [13:13] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name +} + +func init() { file_pluggableharness_memory_v1_types_proto_init() } +func file_pluggableharness_memory_v1_types_proto_init() { + if File_pluggableharness_memory_v1_types_proto != nil { + return + } + file_pluggableharness_memory_v1_types_proto_msgTypes[1].OneofWrappers = []any{} + file_pluggableharness_memory_v1_types_proto_msgTypes[2].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_memory_v1_types_proto_rawDesc), len(file_pluggableharness_memory_v1_types_proto_rawDesc)), + NumEnums: 3, + NumMessages: 5, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_memory_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_memory_v1_types_proto_depIdxs, + EnumInfos: file_pluggableharness_memory_v1_types_proto_enumTypes, + MessageInfos: file_pluggableharness_memory_v1_types_proto_msgTypes, + }.Build() + File_pluggableharness_memory_v1_types_proto = out.File + file_pluggableharness_memory_v1_types_proto_goTypes = nil + file_pluggableharness_memory_v1_types_proto_depIdxs = nil +} diff --git a/pkg/memory/server.go b/pkg/memory/server.go new file mode 100644 index 0000000..d7974e4 --- /dev/null +++ b/pkg/memory/server.go @@ -0,0 +1,290 @@ +package memory + +import ( + "context" + "errors" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + memoryv1 "github.com/pluggableharness/agent/pkg/memory/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" +) + +// Service adapts a Provider into the generated memoryv1.MemoryServiceServer +// and satisfies plugin.Service, so a plugin author's main() can pass it +// straight to plugin.Config.Services. Construct one with NewService. +type Service struct { + memoryv1.UnimplementedMemoryServiceServer + + provider Provider + identity plugin.Identity + callback *plugin.Callback + + // ratifier is non-nil iff provider satisfies RatificationProvider — + // see NewService's doc comment for why this, not + // Provider.Capabilities' own RatificationSupported field, is the + // authoritative signal this Service acts on. + ratifier RatificationProvider + // renderer is non-nil iff provider satisfies Renderer. + renderer Renderer +} + +// NewService builds a Service wrapping provider. identity is this plugin +// build's own self-reported identity, used to answer Describe directly +// (docs/specifications/memory/protocol.md#describe) without involving +// provider. callback is the lazily-dialed kernel callback handle a +// Provider implementation typically closes over to call CountTokens; it is +// held here only so a future RPC that needs it can reach it through +// Service, not because Service itself calls it today. +// +// # Both-or-neither ratification, enforced structurally +// +// NewService type-asserts provider against RatificationProvider exactly +// once. Because Go interface satisfaction is all-or-nothing, this +// assertion can only succeed if provider implements BOTH ApproveRecord and +// RejectRecord — there is no Go-level way to "implement only one" and have +// it succeed. The result of that single assertion is what GetCapabilities +// reports as RatificationSupported (overriding whatever +// Provider.Capabilities itself returns for that field) and what +// ApproveRecord/RejectRecord's handlers gate on — never +// Provider.Capabilities' self-reported value. This means a Provider whose +// Capabilities method claims RatificationSupported: true but which does +// not actually implement RatificationProvider in full is corrected to +// false on the wire, rather than allowed to advertise a capability +// server.go cannot actually route to. +// +// The same reasoning applies to renderer/Renderer for the optional Render +// RPC, though Render has no "both-or-neither" pairing to enforce — it is a +// single optional method. +func NewService(provider Provider, identity plugin.Identity, callback *plugin.Callback) *Service { + svc := &Service{provider: provider, identity: identity, callback: callback} + if r, ok := provider.(RatificationProvider); ok { + svc.ratifier = r + } + if r, ok := provider.(Renderer); ok { + svc.renderer = r + } + return svc +} + +// Register registers this Service's MemoryServiceServer on s, satisfying +// plugin.Service. +func (s *Service) Register(g *grpc.Server) { + memoryv1.RegisterMemoryServiceServer(g, s) +} + +var _ plugin.Service = (*Service)(nil) +var _ memoryv1.MemoryServiceServer = (*Service)(nil) + +// GetCapabilities reports s.provider's capabilities, with +// RatificationSupported forced to reflect whether s.ratifier is set — see +// NewService's doc comment. +func (s *Service) GetCapabilities(ctx context.Context, _ *memoryv1.GetCapabilitiesRequest) (*memoryv1.GetCapabilitiesResponse, error) { + caps, err := s.provider.Capabilities(ctx) + if err != nil { + return nil, toStatus(err) + } + caps.RatificationSupported = s.ratifier != nil + + return &memoryv1.GetCapabilitiesResponse{Capabilities: capabilitiesToProto(caps)}, nil +} + +// Configure decodes s.provider's agent.hcl config block. +func (s *Service) Configure(ctx context.Context, req *memoryv1.ConfigureRequest) (*memoryv1.ConfigureResponse, error) { + if err := s.provider.Configure(ctx, req.GetConfig()); err != nil { + return nil, toStatus(err) + } + return &memoryv1.ConfigureResponse{}, nil +} + +// Recall is the read side: returns the records s.provider judges relevant +// to req. +func (s *Service) Recall(ctx context.Context, req *memoryv1.RecallRequest) (*memoryv1.RecallResponse, error) { + result, err := s.provider.Recall(ctx, recallRequestFromProto(req)) + if err != nil { + return nil, toStatus(err) + } + + records := make([]*memoryv1.MemoryRecord, 0, len(result.Records)) + for _, r := range result.Records { + pb, err := recordToProto(r) + if err != nil { + return nil, toStatus(err) + } + records = append(records, pb) + } + return &memoryv1.RecallResponse{Records: records}, nil +} + +// Record is the write side: creates a new record. +func (s *Service) Record(ctx context.Context, req *memoryv1.RecordRequest) (*memoryv1.RecordResponse, error) { + domainReq, err := recordRequestFromProto(req) + if err != nil { + return nil, toStatus(err) + } + + result, err := s.provider.Record(ctx, domainReq) + if err != nil { + return nil, toStatus(err) + } + if err := s.checkPendingAllowed(result.Status); err != nil { + return nil, toStatus(err) + } + return &memoryv1.RecordResponse{Result: recordResultToProto(result)}, nil +} + +// UpdateRecord replaces an existing record's title/content wholesale. +func (s *Service) UpdateRecord(ctx context.Context, req *memoryv1.UpdateRecordRequest) (*memoryv1.UpdateRecordResponse, error) { + if req.GetId() == "" { + return nil, toStatus(NotFound("id is required")) + } + domainReq, err := updateRecordRequestFromProto(req) + if err != nil { + return nil, toStatus(err) + } + + result, err := s.provider.UpdateRecord(ctx, domainReq) + if err != nil { + return nil, toStatus(err) + } + if err := s.checkPendingAllowed(result.Status); err != nil { + return nil, toStatus(err) + } + return &memoryv1.UpdateRecordResponse{Result: recordResultToProto(result)}, nil +} + +// DeleteRecord removes an existing record. +func (s *Service) DeleteRecord(ctx context.Context, req *memoryv1.DeleteRecordRequest) (*memoryv1.DeleteRecordResponse, error) { + if req.GetId() == "" { + return nil, toStatus(NotFound("id is required")) + } + result, err := s.provider.DeleteRecord(ctx, req.GetId()) + if err != nil { + return nil, toStatus(err) + } + return &memoryv1.DeleteRecordResponse{Result: deleteResultToProto(result)}, nil +} + +// ApproveRecord transitions a PENDING record to CANONICAL. Fails with +// ErrorCategoryRatificationUnsupported unless s.ratifier is set — see +// NewService's doc comment for why that gate is structural, not +// Capabilities-driven. +func (s *Service) ApproveRecord(ctx context.Context, req *memoryv1.ApproveRecordRequest) (*memoryv1.ApproveRecordResponse, error) { + if s.ratifier == nil { + return nil, toStatus(RatificationUnsupported("this provider does not implement ApproveRecord/RejectRecord")) + } + if req.GetId() == "" { + return nil, toStatus(NotFound("id is required")) + } + + result, err := s.ratifier.ApproveRecord(ctx, req.GetId()) + if err != nil { + return nil, toStatus(err) + } + return &memoryv1.ApproveRecordResponse{Result: recordResultToProto(result)}, nil +} + +// RejectRecord discards a pending draft entirely. Fails with +// ErrorCategoryRatificationUnsupported unless s.ratifier is set. +func (s *Service) RejectRecord(ctx context.Context, req *memoryv1.RejectRecordRequest) (*memoryv1.RejectRecordResponse, error) { + if s.ratifier == nil { + return nil, toStatus(RatificationUnsupported("this provider does not implement ApproveRecord/RejectRecord")) + } + if req.GetId() == "" { + return nil, toStatus(NotFound("id is required")) + } + + result, err := s.ratifier.RejectRecord(ctx, req.GetId()) + if err != nil { + return nil, toStatus(err) + } + return &memoryv1.RejectRecordResponse{Result: deleteResultToProto(result)}, nil +} + +// Render returns s.renderer's RenderTree for req. Returns codes.Unimplemented +// if s.provider does not implement Renderer, so the kernel falls back to +// its generic default rendering. +func (s *Service) Render(ctx context.Context, req *memoryv1.RenderRequest) (*memoryv1.RenderResponse, error) { + if s.renderer == nil { + return nil, status.Error(codes.Unimplemented, "memory: this provider does not implement Render") + } + tree, err := s.renderer.Render(ctx, req.GetPayload(), req.GetSchemaVersion()) + if err != nil { + return nil, toStatus(err) + } + return &memoryv1.RenderResponse{Tree: tree}, nil +} + +// ListRecords is the enumeration/audit path: paginated browsing, with +// PENDING records listable without any include_pending-style gate. +func (s *Service) ListRecords(ctx context.Context, req *memoryv1.ListRecordsRequest) (*memoryv1.ListRecordsResponse, error) { + result, err := s.provider.ListRecords(ctx, listRecordsRequestFromProto(req)) + if err != nil { + return nil, toStatus(err) + } + resp, err := listRecordsResultToProto(result) + if err != nil { + return nil, toStatus(err) + } + return resp, nil +} + +// GetRecord fetches exactly one record by id. +func (s *Service) GetRecord(ctx context.Context, req *memoryv1.GetRecordRequest) (*memoryv1.GetRecordResponse, error) { + if req.GetId() == "" { + return nil, toStatus(NotFound("id is required")) + } + record, err := s.provider.GetRecord(ctx, req.GetId()) + if err != nil { + return nil, toStatus(err) + } + pb, err := recordToProto(record) + if err != nil { + return nil, toStatus(err) + } + return &memoryv1.GetRecordResponse{Record: pb}, nil +} + +// Describe reports this plugin build's own identity, independent of +// s.provider (docs/specifications/memory/protocol.md#describe). +func (s *Service) Describe(context.Context, *memoryv1.DescribeRequest) (*memoryv1.DescribeResponse, error) { + return &memoryv1.DescribeResponse{ + Producer: s.identity.ProducerRef(commonv1.Category_CATEGORY_MEMORY), + }, nil +} + +// checkPendingAllowed rejects a RecordStatusPending result from a provider +// that isn't ratification-capable — docs/specifications/memory/protocol.md#ratification-optional's +// "A provider with ratification_supported: false MUST NOT ever return +// status: pending", enforced defensively at the adapter boundary rather +// than trusted to every Provider implementation. +func (s *Service) checkPendingAllowed(status RecordStatus) error { + if status == RecordStatusPending && s.ratifier == nil { + return Unknown("provider returned status pending but does not implement ratification") + } + return nil +} + +// toStatus converts err into the gRPC status error crossing the plugin +// boundary. A cancelled context is reported as codes.Canceled — normal +// control flow, never an application error +// (.claude/rules/grpc.md#context-and-deadlines). An *Error is converted via +// its own grpcStatus. Anything else is reported as ErrorCategoryUnknown / +// codes.Internal, never a bare codes.Unknown. +func toStatus(err error) error { + if err == nil { + return nil + } + if errors.Is(err, context.Canceled) { + return status.Error(codes.Canceled, err.Error()) + } + + var memErr *Error + if errors.As(err, &memErr) { + return memErr.grpcStatus() + } + return Unknown(err.Error()).grpcStatus() +} diff --git a/pkg/memory/server_test.go b/pkg/memory/server_test.go new file mode 100644 index 0000000..e37ca48 --- /dev/null +++ b/pkg/memory/server_test.go @@ -0,0 +1,489 @@ +package memory_test + +import ( + "context" + "errors" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/pluggableharness/agent/pkg/memory" + memoryv1 "github.com/pluggableharness/agent/pkg/memory/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +var errInjected = errors.New("fake: injected failure") + +func testIdentity() plugin.Identity { + return plugin.Identity{Name: "test-memory", Version: "1.0.0", Source: "github.com/agentco/test-memory"} +} + +func TestService_GetCapabilities(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + provider memory.Provider + wantSupports bool + wantErr codes.Code + }{ + { + name: "non-ratifying provider claiming true is corrected to false", + provider: &fakeProvider{ + capabilitiesFunc: func(context.Context) (memory.Capabilities, error) { + return memory.Capabilities{RatificationSupported: true, SupportedTypes: []memory.Type{memory.TypeUser}}, nil + }, + }, + wantSupports: false, + }, + { + name: "ratifier reports true regardless of its own Capabilities claim", + provider: &fakeRatifier{ + fakeProvider: fakeProvider{ + capabilitiesFunc: func(context.Context) (memory.Capabilities, error) { + return memory.Capabilities{RatificationSupported: false}, nil + }, + }, + }, + wantSupports: true, + }, + { + name: "partial ratifier (ApproveRecord only) is treated as incapable", + provider: &fakePartialRatifier{ + fakeProvider: fakeProvider{ + capabilitiesFunc: func(context.Context) (memory.Capabilities, error) { + return memory.Capabilities{RatificationSupported: true}, nil + }, + }, + }, + wantSupports: false, + }, + { + name: "provider error propagates", + provider: &fakeProvider{ + capabilitiesFunc: func(context.Context) (memory.Capabilities, error) { + return memory.Capabilities{}, errInjected + }, + }, + wantErr: codes.Internal, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client := newTestClient(t, memory.NewService(tt.provider, testIdentity(), plugin.NewCallback())) + resp, err := client.GetCapabilities(t.Context(), &memoryv1.GetCapabilitiesRequest{}) + if tt.wantErr != codes.OK { + assertCode(t, err, tt.wantErr) + return + } + if err != nil { + t.Fatalf("GetCapabilities() error = %v, want nil", err) + } + if got := resp.GetCapabilities().GetRatificationSupported(); got != tt.wantSupports { + t.Errorf("GetCapabilities().RatificationSupported = %v, want %v", got, tt.wantSupports) + } + }) + } +} + +func TestService_Configure(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + var called bool + provider := &fakeProvider{configureFunc: func(context.Context, *structpb.Struct) error { + called = true + return nil + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + if _, err := client.Configure(t.Context(), &memoryv1.ConfigureRequest{}); err != nil { + t.Fatalf("Configure() error = %v, want nil", err) + } + if !called { + t.Error("Configure() did not call through to the provider") + } + }) + + t.Run("error propagates", func(t *testing.T) { + t.Parallel() + provider := &fakeProvider{configureFunc: func(context.Context, *structpb.Struct) error { + return memory.SourceUnavailable("db down") + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.Configure(t.Context(), &memoryv1.ConfigureRequest{}) + assertCode(t, err, codes.Unavailable) + }) +} + +func TestService_Recall(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + provider := &fakeProvider{recallFunc: func(_ context.Context, req memory.RecallRequest) (memory.RecallResult, error) { + if req.TokenBudget != 100 { + t.Errorf("RecallRequest.TokenBudget = %d, want 100", req.TokenBudget) + } + return memory.RecallResult{Records: []memory.Record{{ID: "r1", Type: memory.TypeProject, Scope: memory.ScopeProject, Content: "hello"}}}, nil + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + resp, err := client.Recall(t.Context(), &memoryv1.RecallRequest{TokenBudget: 100}) + if err != nil { + t.Fatalf("Recall() error = %v, want nil", err) + } + if len(resp.GetRecords()) != 1 || resp.GetRecords()[0].GetId() != "r1" { + t.Errorf("Recall() records = %v, want one record with id r1", resp.GetRecords()) + } + }) + + t.Run("budget exceeded maps to ResourceExhausted", func(t *testing.T) { + t.Parallel() + provider := &fakeProvider{recallFunc: func(context.Context, memory.RecallRequest) (memory.RecallResult, error) { + return memory.RecallResult{}, memory.BudgetExceeded("too many candidates") + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.Recall(t.Context(), &memoryv1.RecallRequest{}) + assertCode(t, err, codes.ResourceExhausted) + }) + + t.Run("canceled context maps to Canceled, not an application error", func(t *testing.T) { + t.Parallel() + provider := &fakeProvider{recallFunc: func(context.Context, memory.RecallRequest) (memory.RecallResult, error) { + return memory.RecallResult{}, context.Canceled + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.Recall(t.Context(), &memoryv1.RecallRequest{}) + assertCode(t, err, codes.Canceled) + }) +} + +func TestService_Record(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + provider := &fakeProvider{recordFunc: func(_ context.Context, req memory.RecordRequest) (memory.RecordResult, error) { + if req.Content != "hello" { + t.Errorf("RecordRequest.Content = %q, want %q", req.Content, "hello") + } + return memory.RecordResult{ID: "hello-1", Status: memory.RecordStatusCanonical}, nil + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + resp, err := client.Record(t.Context(), &memoryv1.RecordRequest{Content: textBlocks("hello")}) + if err != nil { + t.Fatalf("Record() error = %v, want nil", err) + } + if resp.GetResult().GetId() != "hello-1" { + t.Errorf("Record().Result.Id = %q, want %q", resp.GetResult().GetId(), "hello-1") + } + }) + + t.Run("pending status from a non-ratifying provider is rejected", func(t *testing.T) { + t.Parallel() + provider := &fakeProvider{recordFunc: func(context.Context, memory.RecordRequest) (memory.RecordResult, error) { + return memory.RecordResult{ID: "x", Status: memory.RecordStatusPending}, nil + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.Record(t.Context(), &memoryv1.RecordRequest{}) + assertCode(t, err, codes.Internal) + }) + + t.Run("pending status from a ratifying provider is allowed", func(t *testing.T) { + t.Parallel() + provider := &fakeRatifier{fakeProvider: fakeProvider{recordFunc: func(context.Context, memory.RecordRequest) (memory.RecordResult, error) { + return memory.RecordResult{ID: "x", Status: memory.RecordStatusPending}, nil + }}} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + resp, err := client.Record(t.Context(), &memoryv1.RecordRequest{}) + if err != nil { + t.Fatalf("Record() error = %v, want nil", err) + } + if resp.GetResult().GetStatus() != memoryv1.RecordStatus_RECORD_STATUS_PENDING { + t.Errorf("Record().Result.Status = %v, want PENDING", resp.GetResult().GetStatus()) + } + }) + + t.Run("non-text content is rejected", func(t *testing.T) { + t.Parallel() + provider := &fakeProvider{} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.Record(t.Context(), &memoryv1.RecordRequest{Content: imageBlocks()}) + assertCode(t, err, codes.Internal) + }) +} + +func TestService_UpdateRecord(t *testing.T) { + t.Parallel() + + t.Run("missing id fails not_found without calling the provider", func(t *testing.T) { + t.Parallel() + var called bool + provider := &fakeProvider{updateRecordFunc: func(context.Context, memory.UpdateRecordRequest) (memory.RecordResult, error) { + called = true + return memory.RecordResult{}, nil + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.UpdateRecord(t.Context(), &memoryv1.UpdateRecordRequest{}) + assertCode(t, err, codes.NotFound) + if called { + t.Error("UpdateRecord() called through to the provider with an empty id") + } + }) + + t.Run("unknown id fails not_found", func(t *testing.T) { + t.Parallel() + provider := &fakeProvider{updateRecordFunc: func(context.Context, memory.UpdateRecordRequest) (memory.RecordResult, error) { + return memory.RecordResult{}, memory.NotFound("no such record") + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.UpdateRecord(t.Context(), &memoryv1.UpdateRecordRequest{Id: "missing"}) + assertCode(t, err, codes.NotFound) + }) + + t.Run("success replaces content wholesale", func(t *testing.T) { + t.Parallel() + provider := &fakeProvider{updateRecordFunc: func(_ context.Context, req memory.UpdateRecordRequest) (memory.RecordResult, error) { + if req.Content != "new content" { + t.Errorf("UpdateRecordRequest.Content = %q, want %q", req.Content, "new content") + } + return memory.RecordResult{ID: req.ID, Status: memory.RecordStatusCanonical}, nil + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.UpdateRecord(t.Context(), &memoryv1.UpdateRecordRequest{Id: "r1", Content: textBlocks("new content")}) + if err != nil { + t.Fatalf("UpdateRecord() error = %v, want nil", err) + } + }) +} + +func TestService_DeleteRecord(t *testing.T) { + t.Parallel() + + t.Run("missing id fails not_found without calling the provider", func(t *testing.T) { + t.Parallel() + var called bool + provider := &fakeProvider{deleteRecordFunc: func(context.Context, string) (memory.DeleteResult, error) { + called = true + return memory.DeleteResult{}, nil + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.DeleteRecord(t.Context(), &memoryv1.DeleteRecordRequest{}) + assertCode(t, err, codes.NotFound) + if called { + t.Error("DeleteRecord() called through to the provider with an empty id") + } + }) + + t.Run("success", func(t *testing.T) { + t.Parallel() + provider := &fakeProvider{deleteRecordFunc: func(context.Context, string) (memory.DeleteResult, error) { + return memory.DeleteResult{Deleted: true}, nil + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + resp, err := client.DeleteRecord(t.Context(), &memoryv1.DeleteRecordRequest{Id: "r1"}) + if err != nil { + t.Fatalf("DeleteRecord() error = %v, want nil", err) + } + if !resp.GetResult().GetDeleted() { + t.Error("DeleteRecord().Result.Deleted = false, want true") + } + }) +} + +func TestService_ApproveRejectRecord(t *testing.T) { + t.Parallel() + + t.Run("non-ratifying provider fails ratification_unsupported", func(t *testing.T) { + t.Parallel() + provider := &fakeProvider{} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + + _, err := client.ApproveRecord(t.Context(), &memoryv1.ApproveRecordRequest{Id: "r1"}) + assertCode(t, err, codes.FailedPrecondition) + + _, err = client.RejectRecord(t.Context(), &memoryv1.RejectRecordRequest{Id: "r1"}) + assertCode(t, err, codes.FailedPrecondition) + }) + + t.Run("partial ratifier (ApproveRecord only) still fails ratification_unsupported", func(t *testing.T) { + t.Parallel() + provider := &fakePartialRatifier{} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + + _, err := client.ApproveRecord(t.Context(), &memoryv1.ApproveRecordRequest{Id: "r1"}) + assertCode(t, err, codes.FailedPrecondition) + }) + + t.Run("ratifier approves and rejects", func(t *testing.T) { + t.Parallel() + provider := &fakeRatifier{ + approveFunc: func(_ context.Context, id string) (memory.RecordResult, error) { + return memory.RecordResult{ID: id, Status: memory.RecordStatusCanonical}, nil + }, + rejectFunc: func(context.Context, string) (memory.DeleteResult, error) { + return memory.DeleteResult{Deleted: true}, nil + }, + } + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + + approveResp, err := client.ApproveRecord(t.Context(), &memoryv1.ApproveRecordRequest{Id: "r1"}) + if err != nil { + t.Fatalf("ApproveRecord() error = %v, want nil", err) + } + if approveResp.GetResult().GetStatus() != memoryv1.RecordStatus_RECORD_STATUS_CANONICAL { + t.Errorf("ApproveRecord().Result.Status = %v, want CANONICAL", approveResp.GetResult().GetStatus()) + } + + rejectResp, err := client.RejectRecord(t.Context(), &memoryv1.RejectRecordRequest{Id: "r1"}) + if err != nil { + t.Fatalf("RejectRecord() error = %v, want nil", err) + } + if !rejectResp.GetResult().GetDeleted() { + t.Error("RejectRecord().Result.Deleted = false, want true") + } + }) + + t.Run("missing id fails not_found", func(t *testing.T) { + t.Parallel() + provider := &fakeRatifier{} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.ApproveRecord(t.Context(), &memoryv1.ApproveRecordRequest{}) + assertCode(t, err, codes.NotFound) + }) +} + +func TestService_Render(t *testing.T) { + t.Parallel() + + t.Run("unimplemented without a renderer", func(t *testing.T) { + t.Parallel() + provider := &fakeProvider{} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.Render(t.Context(), &memoryv1.RenderRequest{}) + assertCode(t, err, codes.Unimplemented) + }) + + t.Run("renderer returns a tree", func(t *testing.T) { + t.Parallel() + provider := &fakeRenderer{renderFunc: func(_ context.Context, _ []byte, schemaVersion string) (*renderv1.RenderTree, error) { + if schemaVersion != "v1" { + t.Errorf("schemaVersion = %q, want %q", schemaVersion, "v1") + } + return &renderv1.RenderTree{}, nil + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + resp, err := client.Render(t.Context(), &memoryv1.RenderRequest{SchemaVersion: "v1"}) + if err != nil { + t.Fatalf("Render() error = %v, want nil", err) + } + if resp.GetTree() == nil { + t.Error("Render().Tree = nil, want non-nil") + } + }) +} + +func TestService_ListRecords(t *testing.T) { + t.Parallel() + + provider := &fakeProvider{listRecordsFunc: func(_ context.Context, req memory.ListRecordsRequest) (memory.ListRecordsResult, error) { + if req.StatusFilter != nil { + t.Errorf("StatusFilter = %v, want nil (both canonical and pending eligible)", *req.StatusFilter) + } + return memory.ListRecordsResult{ + Records: []memory.Record{{ID: "r1"}, {ID: "r2"}}, + NextPageToken: "next", + }, nil + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + resp, err := client.ListRecords(t.Context(), &memoryv1.ListRecordsRequest{}) + if err != nil { + t.Fatalf("ListRecords() error = %v, want nil", err) + } + if len(resp.GetRecords()) != 2 { + t.Errorf("ListRecords() records = %d, want 2", len(resp.GetRecords())) + } + if resp.GetNextPageToken() != "next" { + t.Errorf("ListRecords().NextPageToken = %q, want %q", resp.GetNextPageToken(), "next") + } +} + +func TestService_GetRecord(t *testing.T) { + t.Parallel() + + t.Run("missing id fails not_found without calling the provider", func(t *testing.T) { + t.Parallel() + var called bool + provider := &fakeProvider{getRecordFunc: func(context.Context, string) (memory.Record, error) { + called = true + return memory.Record{}, nil + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.GetRecord(t.Context(), &memoryv1.GetRecordRequest{}) + assertCode(t, err, codes.NotFound) + if called { + t.Error("GetRecord() called through to the provider with an empty id") + } + }) + + t.Run("unknown id fails not_found", func(t *testing.T) { + t.Parallel() + provider := &fakeProvider{getRecordFunc: func(context.Context, string) (memory.Record, error) { + return memory.Record{}, memory.NotFound("no such record") + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + _, err := client.GetRecord(t.Context(), &memoryv1.GetRecordRequest{Id: "missing"}) + assertCode(t, err, codes.NotFound) + }) + + t.Run("success", func(t *testing.T) { + t.Parallel() + provider := &fakeProvider{getRecordFunc: func(_ context.Context, id string) (memory.Record, error) { + return memory.Record{ID: id, Type: memory.TypeReference, Scope: memory.ScopeGlobal, Content: "pointer"}, nil + }} + client := newTestClient(t, memory.NewService(provider, testIdentity(), plugin.NewCallback())) + resp, err := client.GetRecord(t.Context(), &memoryv1.GetRecordRequest{Id: "r1"}) + if err != nil { + t.Fatalf("GetRecord() error = %v, want nil", err) + } + if resp.GetRecord().GetId() != "r1" { + t.Errorf("GetRecord().Record.Id = %q, want %q", resp.GetRecord().GetId(), "r1") + } + }) +} + +func TestService_Describe(t *testing.T) { + t.Parallel() + + identity := testIdentity() + client := newTestClient(t, memory.NewService(&fakeProvider{}, identity, plugin.NewCallback())) + resp, err := client.Describe(t.Context(), &memoryv1.DescribeRequest{}) + if err != nil { + t.Fatalf("Describe() error = %v, want nil", err) + } + producer := resp.GetProducer() + if producer.GetName() != identity.Name || producer.GetVersion() != identity.Version || producer.GetSource() != identity.Source { + t.Errorf("Describe().Producer = %+v, want name/version/source matching identity %+v", producer, identity) + } +} + +// assertCode fails t unless err is a gRPC status error carrying want. +func assertCode(t *testing.T, err error, want codes.Code) { + t.Helper() + if err == nil { + t.Fatalf("error = nil, want code %v", want) + } + st, ok := status.FromError(err) + if !ok { + t.Fatalf("error = %v, not a gRPC status error", err) + } + if st.Code() != want { + t.Errorf("error code = %v, want %v", st.Code(), want) + } +} diff --git a/pkg/memory/tokens_test.go b/pkg/memory/tokens_test.go new file mode 100644 index 0000000..19532ce --- /dev/null +++ b/pkg/memory/tokens_test.go @@ -0,0 +1,24 @@ +package memory_test + +import ( + "testing" + + "github.com/pluggableharness/agent/pkg/memory" + "github.com/pluggableharness/agent/pkg/plugin" +) + +// TestCountTokens_UnconnectedCallback exercises CountTokens' error path: a +// *plugin.Callback that was never handed a broker (i.e. never went through +// plugin.Serve's real subprocess wiring) fails to dial, the same +// documented limitation pkg/kernel.Client.Dial carries — there is no way +// to construct a real *plugin.GRPCBroker from outside hashicorp/go-plugin +// in a unit test. This still exercises CountTokens' own error-wrapping +// path end to end. +func TestCountTokens_UnconnectedCallback(t *testing.T) { + t.Parallel() + + _, err := memory.CountTokens(t.Context(), plugin.NewCallback(), "claude-x", "hello world") + if err == nil { + t.Fatal("CountTokens() error = nil, want an error from the unconnected callback") + } +} diff --git a/pkg/metric/proto/v1/types.pb.go b/pkg/metric/proto/v1/types.pb.go new file mode 100644 index 0000000..30cb440 --- /dev/null +++ b/pkg/metric/proto/v1/types.pb.go @@ -0,0 +1,343 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/metric/v1/types.proto + +// Package pluggableharness.metric.v1 defines the wire shape of one relayed +// metric observation, described in specifications/observability.md and +// consumed by pluggableharness.kernel.v1's RecordMetrics RPC +// (KernelCallbackService). Unlike pluggableharness.trace.v1's Span, a +// MetricRecord is NOT relayed transparently: the kernel records each +// observation against its own, kernel-owned instrument rather than +// forwarding it as OTLP, and bounds `attributes`' key set before the +// observation reaches any exporter +// (observability.md#the-tracing-metrics-asymmetry) — the non-negotiable +// metric-cardinality rule in .claude/rules/logging-telemetry.md would +// otherwise let an arbitrary third-party plugin hand the kernel an +// unbounded attribute set. + +package metricv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// MetricKind identifies which of OTel's three instrument shapes a +// MetricRecord observation belongs to. +type MetricKind int32 + +const ( + // Zero value. Never valid on the wire; its presence means a caller + // forgot to set the field. + MetricKind_METRIC_KIND_UNSPECIFIED MetricKind = 0 + // A monotonically increasing sum, e.g. a request count. + MetricKind_METRIC_KIND_COUNTER MetricKind = 1 + // A sum that can both increase and decrease, e.g. an active-connection + // gauge. + MetricKind_METRIC_KIND_UP_DOWN_COUNTER MetricKind = 2 + // One observation to be aggregated into a distribution, e.g. a call + // duration. A MetricRecord carries exactly one observation, never a + // pre-aggregated bucket set — the kernel's own histogram instrument + // performs the aggregation, the same way an OTel Histogram instrument's + // Record(ctx, value) call does on the reporting side. + MetricKind_METRIC_KIND_HISTOGRAM MetricKind = 3 +) + +// Enum value maps for MetricKind. +var ( + MetricKind_name = map[int32]string{ + 0: "METRIC_KIND_UNSPECIFIED", + 1: "METRIC_KIND_COUNTER", + 2: "METRIC_KIND_UP_DOWN_COUNTER", + 3: "METRIC_KIND_HISTOGRAM", + } + MetricKind_value = map[string]int32{ + "METRIC_KIND_UNSPECIFIED": 0, + "METRIC_KIND_COUNTER": 1, + "METRIC_KIND_UP_DOWN_COUNTER": 2, + "METRIC_KIND_HISTOGRAM": 3, + } +) + +func (x MetricKind) Enum() *MetricKind { + p := new(MetricKind) + *p = x + return p +} + +func (x MetricKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (MetricKind) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_metric_v1_types_proto_enumTypes[0].Descriptor() +} + +func (MetricKind) Type() protoreflect.EnumType { + return &file_pluggableharness_metric_v1_types_proto_enumTypes[0] +} + +func (x MetricKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use MetricKind.Descriptor instead. +func (MetricKind) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_metric_v1_types_proto_rawDescGZIP(), []int{0} +} + +// MetricRecord is one metric observation, relayed by RecordMetrics. +type MetricRecord struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The metric's name. MUST be set. The kernel records this observation + // against an instrument named "plugin.{category}.{name}.{metric name}" + // — {category}/{name} come from the calling plugin's server-derived + // producer identity, never from a field on this message + // (kernel-callbacks.md's anti-spoof rule, applied here identically to + // Emit/Log/Publish). + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // A human-readable description of what this metric measures. MAY be + // empty. + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + // The metric's unit, UCUM-style (e.g. "ms", "By", "1"). MAY be empty. + Unit string `protobuf:"bytes,3,opt,name=unit,proto3" json:"unit,omitempty"` + // Which instrument shape this observation belongs to. MUST be set. The + // kernel MUST reject a RecordMetrics call whose kind disagrees with a + // previously-created instrument of the same name. + Kind MetricKind `protobuf:"varint,4,opt,name=kind,proto3,enum=pluggableharness.metric.v1.MetricKind" json:"kind,omitempty"` + // The observed value. Exactly one variant MUST be set. + // + // Types that are valid to be assigned to Value: + // + // *MetricRecord_IntValue + // *MetricRecord_DoubleValue + Value isMetricRecord_Value `protobuf_oneof:"value"` + // Open-ended key/value attributes for this observation (e.g. a status + // label). A genuine open-ended-by-design case, not a structured-payload + // dodge (.claude/rules/proto.md's map carve-out) — but + // unlike trace.v1.Span's Struct-typed attributes, the kernel bounds this + // map's key set per instrument before the observation reaches any + // exporter (observability.md#the-tracing-metrics-asymmetry); a key + // beyond the bound is dropped, not rejected, with a throttled WARN log + // identifying what was dropped. + Attributes map[string]string `protobuf:"bytes,7,rep,name=attributes,proto3" json:"attributes,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // When this observation occurred at the plugin. MUST be set. + Time *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=time,proto3" json:"time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MetricRecord) Reset() { + *x = MetricRecord{} + mi := &file_pluggableharness_metric_v1_types_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MetricRecord) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MetricRecord) ProtoMessage() {} + +func (x *MetricRecord) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_metric_v1_types_proto_msgTypes[0] + 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 MetricRecord.ProtoReflect.Descriptor instead. +func (*MetricRecord) Descriptor() ([]byte, []int) { + return file_pluggableharness_metric_v1_types_proto_rawDescGZIP(), []int{0} +} + +func (x *MetricRecord) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *MetricRecord) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *MetricRecord) GetUnit() string { + if x != nil { + return x.Unit + } + return "" +} + +func (x *MetricRecord) GetKind() MetricKind { + if x != nil { + return x.Kind + } + return MetricKind_METRIC_KIND_UNSPECIFIED +} + +func (x *MetricRecord) GetValue() isMetricRecord_Value { + if x != nil { + return x.Value + } + return nil +} + +func (x *MetricRecord) GetIntValue() int64 { + if x != nil { + if x, ok := x.Value.(*MetricRecord_IntValue); ok { + return x.IntValue + } + } + return 0 +} + +func (x *MetricRecord) GetDoubleValue() float64 { + if x != nil { + if x, ok := x.Value.(*MetricRecord_DoubleValue); ok { + return x.DoubleValue + } + } + return 0 +} + +func (x *MetricRecord) GetAttributes() map[string]string { + if x != nil { + return x.Attributes + } + return nil +} + +func (x *MetricRecord) GetTime() *timestamppb.Timestamp { + if x != nil { + return x.Time + } + return nil +} + +type isMetricRecord_Value interface { + isMetricRecord_Value() +} + +type MetricRecord_IntValue struct { + // An integer observation. + IntValue int64 `protobuf:"varint,5,opt,name=int_value,json=intValue,proto3,oneof"` +} + +type MetricRecord_DoubleValue struct { + // A floating-point observation. + DoubleValue float64 `protobuf:"fixed64,6,opt,name=double_value,json=doubleValue,proto3,oneof"` +} + +func (*MetricRecord_IntValue) isMetricRecord_Value() {} + +func (*MetricRecord_DoubleValue) isMetricRecord_Value() {} + +var File_pluggableharness_metric_v1_types_proto protoreflect.FileDescriptor + +const file_pluggableharness_metric_v1_types_proto_rawDesc = "" + + "\n" + + "&pluggableharness/metric/v1/types.proto\x12\x1apluggableharness.metric.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xaa\x03\n" + + "\fMetricRecord\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x12\n" + + "\x04unit\x18\x03 \x01(\tR\x04unit\x12:\n" + + "\x04kind\x18\x04 \x01(\x0e2&.pluggableharness.metric.v1.MetricKindR\x04kind\x12\x1d\n" + + "\tint_value\x18\x05 \x01(\x03H\x00R\bintValue\x12#\n" + + "\fdouble_value\x18\x06 \x01(\x01H\x00R\vdoubleValue\x12X\n" + + "\n" + + "attributes\x18\a \x03(\v28.pluggableharness.metric.v1.MetricRecord.AttributesEntryR\n" + + "attributes\x12.\n" + + "\x04time\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\x04time\x1a=\n" + + "\x0fAttributesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\a\n" + + "\x05value*~\n" + + "\n" + + "MetricKind\x12\x1b\n" + + "\x17METRIC_KIND_UNSPECIFIED\x10\x00\x12\x17\n" + + "\x13METRIC_KIND_COUNTER\x10\x01\x12\x1f\n" + + "\x1bMETRIC_KIND_UP_DOWN_COUNTER\x10\x02\x12\x19\n" + + "\x15METRIC_KIND_HISTOGRAM\x10\x03B@Z>github.com/pluggableharness/agent/pkg/metric/proto/v1;metricv1b\x06proto3" + +var ( + file_pluggableharness_metric_v1_types_proto_rawDescOnce sync.Once + file_pluggableharness_metric_v1_types_proto_rawDescData []byte +) + +func file_pluggableharness_metric_v1_types_proto_rawDescGZIP() []byte { + file_pluggableharness_metric_v1_types_proto_rawDescOnce.Do(func() { + file_pluggableharness_metric_v1_types_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_metric_v1_types_proto_rawDesc), len(file_pluggableharness_metric_v1_types_proto_rawDesc))) + }) + return file_pluggableharness_metric_v1_types_proto_rawDescData +} + +var file_pluggableharness_metric_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_pluggableharness_metric_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_pluggableharness_metric_v1_types_proto_goTypes = []any{ + (MetricKind)(0), // 0: pluggableharness.metric.v1.MetricKind + (*MetricRecord)(nil), // 1: pluggableharness.metric.v1.MetricRecord + nil, // 2: pluggableharness.metric.v1.MetricRecord.AttributesEntry + (*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp +} +var file_pluggableharness_metric_v1_types_proto_depIdxs = []int32{ + 0, // 0: pluggableharness.metric.v1.MetricRecord.kind:type_name -> pluggableharness.metric.v1.MetricKind + 2, // 1: pluggableharness.metric.v1.MetricRecord.attributes:type_name -> pluggableharness.metric.v1.MetricRecord.AttributesEntry + 3, // 2: pluggableharness.metric.v1.MetricRecord.time:type_name -> google.protobuf.Timestamp + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_pluggableharness_metric_v1_types_proto_init() } +func file_pluggableharness_metric_v1_types_proto_init() { + if File_pluggableharness_metric_v1_types_proto != nil { + return + } + file_pluggableharness_metric_v1_types_proto_msgTypes[0].OneofWrappers = []any{ + (*MetricRecord_IntValue)(nil), + (*MetricRecord_DoubleValue)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_metric_v1_types_proto_rawDesc), len(file_pluggableharness_metric_v1_types_proto_rawDesc)), + NumEnums: 1, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_metric_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_metric_v1_types_proto_depIdxs, + EnumInfos: file_pluggableharness_metric_v1_types_proto_enumTypes, + MessageInfos: file_pluggableharness_metric_v1_types_proto_msgTypes, + }.Build() + File_pluggableharness_metric_v1_types_proto = out.File + file_pluggableharness_metric_v1_types_proto_goTypes = nil + file_pluggableharness_metric_v1_types_proto_depIdxs = nil +} diff --git a/pkg/model/capabilities.go b/pkg/model/capabilities.go new file mode 100644 index 0000000..ddf6cc1 --- /dev/null +++ b/pkg/model/capabilities.go @@ -0,0 +1,191 @@ +package model + +import ( + "fmt" + "time" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" +) + +// capabilitiesOptions collects CapabilitiesOption values, mirroring +// pkg/config's attributeOptions shape. +type capabilitiesOptions struct { + slashCommands []*commonv1.PromptExpansionSpec + supportedHookPoints []commonv1.HookPoint +} + +// CapabilitiesOption configures one optional field of a Capabilities value +// built by NewCapabilities. +type CapabilitiesOption func(*capabilitiesOptions) + +// WithSlashCommands sets the prompt-expansion slash commands this provider +// contributes, per docs/specifications/model/protocol.md#getcapabilities. +func WithSlashCommands(specs ...*commonv1.PromptExpansionSpec) CapabilitiesOption { + return func(o *capabilitiesOptions) { o.slashCommands = specs } +} + +// WithSupportedHookPoints sets which hook points this plugin can serve via +// HookSubscriberService.DispatchHook, per +// docs/specifications/model/data-types.md#capabilitiessupported_hook_points. +func WithSupportedHookPoints(points ...commonv1.HookPoint) CapabilitiesOption { + return func(o *capabilitiesOptions) { o.supportedHookPoints = points } +} + +// NewCapabilities builds a Capabilities value from models and configSchema, +// validating every MUST-level invariant docs/specifications/model/data-types.md#modelspec +// and docs/specifications/model/data-types.md#pricing describe before +// returning it. A caller gets back either a known-good *Capabilities or an +// error identifying which invariant it violated (compare with errors.Is +// against ErrInvalidCapabilities or ErrInvalidPricing). +func NewCapabilities(models []Spec, configSchema *configv1.ConfigSchema, opts ...CapabilitiesOption) (*Capabilities, error) { + var o capabilitiesOptions + for _, opt := range opts { + opt(&o) + } + + c := &Capabilities{ + Models: models, + SlashCommands: o.slashCommands, + ConfigSchema: configSchema, + SupportedHookPoints: o.supportedHookPoints, + } + if err := validateCapabilities(c); err != nil { + return nil, err + } + return c, nil +} + +// validateCapabilities checks c against docs/specifications/model/data-types.md#modelspec's +// MUST-level rules: at least one model, a config schema present, and every +// model's own invariants (validateModelSpec). +func validateCapabilities(c *Capabilities) error { + if len(c.Models) == 0 { + return fmt.Errorf("%w: at least one model required", ErrInvalidCapabilities) + } + if c.ConfigSchema == nil { + return fmt.Errorf("%w: config schema required", ErrInvalidCapabilities) + } + for i, m := range c.Models { + if err := validateModelSpec(m); err != nil { + return fmt.Errorf("%w: model %d (%q): %w", ErrInvalidCapabilities, i, m.ID, err) + } + } + return nil +} + +// validateModelSpec checks m against docs/specifications/model/data-types.md#modelspec's +// MUST-level rules for a single Spec: a non-empty id, ThinkingSpec's +// mode-dependent requirements (effort_levels/budget_range/default), and +// Pricing's own invariants (validatePricing). +func validateModelSpec(m Spec) error { + if m.ID == "" { + return fmt.Errorf("%w: id required", ErrInvalidCapabilities) + } + if err := validateThinkingSpec(m.Thinking); err != nil { + return err + } + if err := validatePricing(m.Pricing, m.Caching.Supported); err != nil { + return err + } + return nil +} + +// validateThinkingSpec checks t against +// docs/specifications/model/data-types.md#thinkingspec: effort_levels +// required for THINKING_MODE_DISCRETE_EFFORT, budget_range required for +// THINKING_MODE_CONTINUOUS_BUDGET, and default required whenever mode is +// not THINKING_MODE_NONE +// (docs/specifications/model/conformance.md's "ThinkingSpec.default MUST +// be set when mode != none" row). +func validateThinkingSpec(t ThinkingSpec) error { + if t.Mode == modelv1.ThinkingMode_THINKING_MODE_NONE { + return nil + } + if t.Mode == modelv1.ThinkingMode_THINKING_MODE_DISCRETE_EFFORT && len(t.EffortLevels) == 0 { + return fmt.Errorf("%w: effort_levels required for THINKING_MODE_DISCRETE_EFFORT", ErrInvalidCapabilities) + } + if t.Mode == modelv1.ThinkingMode_THINKING_MODE_CONTINUOUS_BUDGET && t.BudgetRange == nil { + return fmt.Errorf("%w: budget_range required for THINKING_MODE_CONTINUOUS_BUDGET", ErrInvalidCapabilities) + } + if t.Default == "" { + return fmt.Errorf("%w: default required when mode is not THINKING_MODE_NONE", ErrInvalidCapabilities) + } + return nil +} + +// validatePricing checks p against docs/specifications/model/data-types.md#pricing: +// currency set, at least one tier unless free, cache pricing present on +// every tier iff cachingSupported, and no two tiers overlapping across +// both the time dimension (effective_from/effective_until) and the +// input-size dimension (input_tokens_from/input_tokens_until) +// simultaneously. +// +// Judgment call: the spec also requires rejecting a *gapped* tier set (no +// (timestamp, input_token_count) pair left unmatched), not just an +// overlapping one. Detecting a gap in a general two-dimensional, +// partially-unbounded interval set is a materially harder problem than +// detecting an overlap (it requires reconstructing the full covered region +// and comparing it against the unbounded plane) and the task brief +// explicitly cautions against over-engineering a fully generic tier +// validator here. Overlap detection catches the more common authoring +// mistake (two tiers both claiming the same moment/input-size) and is a +// straightforward, tractable pairwise check; gap detection is left +// unimplemented, consistent with docs/specifications/model/conformance.md's +// own open question about how strict this check should ultimately be. +func validatePricing(p Pricing, cachingSupported bool) error { + if p.Currency == "" { + return fmt.Errorf("%w: currency required", ErrInvalidPricing) + } + if !p.Free && len(p.Tiers) == 0 { + return fmt.Errorf("%w: at least one tier required unless free", ErrInvalidPricing) + } + for i, t := range p.Tiers { + if cachingSupported && (t.CacheWritePerMtok == nil || t.CacheReadPerMtok == nil) { + return fmt.Errorf("%w: tier %d missing cache pricing though caching is supported", ErrInvalidPricing, i) + } + for j := i + 1; j < len(p.Tiers); j++ { + if tiersOverlap(t, p.Tiers[j]) { + return fmt.Errorf("%w: tier %d and tier %d overlap", ErrInvalidPricing, i, j) + } + } + } + return nil +} + +// tiersOverlap reports whether a and b could both match the same +// (timestamp, input_token_count) pair — their time ranges overlap AND +// their input-token ranges overlap simultaneously, per +// docs/specifications/model/data-types.md#pricing's two-dimensional +// tier-matching rule. +func tiersOverlap(a, b PricingTier) bool { + return timeRangesOverlap(a.EffectiveFrom, a.EffectiveUntil, b.EffectiveFrom, b.EffectiveUntil) && + int64RangesOverlap(a.InputTokensFrom, a.InputTokensUntil, b.InputTokensFrom, b.InputTokensUntil) +} + +// timeRangesOverlap reports whether half-open ranges [aFrom, aUntil) and +// [bFrom, bUntil) overlap, where a nil bound is unbounded on that side. +func timeRangesOverlap(aFrom, aUntil, bFrom, bUntil *time.Time) bool { + // aFrom < bUntil (or bUntil unbounded) AND bFrom < aUntil (or aUntil + // unbounded). + if aUntil != nil && bFrom != nil && !bFrom.Before(*aUntil) { + return false + } + if bUntil != nil && aFrom != nil && !aFrom.Before(*bUntil) { + return false + } + return true +} + +// int64RangesOverlap reports whether half-open ranges [aFrom, aUntil) and +// [bFrom, bUntil) overlap, where a nil bound is unbounded on that side. +func int64RangesOverlap(aFrom, aUntil, bFrom, bUntil *int64) bool { + if aUntil != nil && bFrom != nil && *bFrom >= *aUntil { + return false + } + if bUntil != nil && aFrom != nil && *aFrom >= *bUntil { + return false + } + return true +} diff --git a/pkg/model/capabilities_test.go b/pkg/model/capabilities_test.go new file mode 100644 index 0000000..6abd166 --- /dev/null +++ b/pkg/model/capabilities_test.go @@ -0,0 +1,252 @@ +package model_test + +import ( + "errors" + "testing" + "time" + + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + model "github.com/pluggableharness/agent/pkg/model" + modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" +) + +func mustFloat64(f float64) *float64 { return &f } +func mustInt64(i int64) *int64 { return &i } + +// validModelSpec returns a minimal, invariant-satisfying Spec a test +// can mutate to exercise one specific violation at a time. +func validModelSpec() model.Spec { + return model.Spec{ + ID: "claude-test", + ContextWindow: 200000, + MaxOutputTokens: 8192, + SupportsToolUse: true, + SupportsVision: true, + SupportsStreaming: true, + Thinking: model.ThinkingSpec{Mode: modelv1.ThinkingMode_THINKING_MODE_NONE}, + Caching: model.CachingSpec{Mode: modelv1.CachingMode_CACHING_MODE_NONE}, + Pricing: model.Pricing{ + Currency: "USD", + Tiers: []model.PricingTier{ + {InputPerMtok: 3, OutputPerMtok: 15}, + }, + }, + } +} + +func validConfigSchema(t *testing.T) *configv1.ConfigSchema { + t.Helper() + return &configv1.ConfigSchema{} +} + +func TestNewCapabilities_Valid(t *testing.T) { + t.Parallel() + + caps, err := model.NewCapabilities([]model.Spec{validModelSpec()}, validConfigSchema(t)) + if err != nil { + t.Fatalf("NewCapabilities() = %v, want nil error", err) + } + if len(caps.Models) != 1 { + t.Errorf("len(caps.Models) = %d, want 1", len(caps.Models)) + } +} + +func TestNewCapabilities_Invalid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + models []model.Spec + schema *configv1.ConfigSchema + wantErr error + }{ + { + name: "no models", + models: nil, + schema: &configv1.ConfigSchema{}, + wantErr: model.ErrInvalidCapabilities, + }, + { + name: "no config schema", + models: []model.Spec{validModelSpec()}, + schema: nil, + wantErr: model.ErrInvalidCapabilities, + }, + { + name: "model missing id", + models: func() []model.Spec { + m := validModelSpec() + m.ID = "" + return []model.Spec{m} + }(), + schema: &configv1.ConfigSchema{}, + wantErr: model.ErrInvalidCapabilities, + }, + { + name: "discrete effort without effort levels", + models: func() []model.Spec { + m := validModelSpec() + m.Thinking = model.ThinkingSpec{ + Supported: true, + Mode: modelv1.ThinkingMode_THINKING_MODE_DISCRETE_EFFORT, + Default: "medium", + } + return []model.Spec{m} + }(), + schema: &configv1.ConfigSchema{}, + wantErr: model.ErrInvalidCapabilities, + }, + { + name: "continuous budget without budget range", + models: func() []model.Spec { + m := validModelSpec() + m.Thinking = model.ThinkingSpec{ + Supported: true, + Mode: modelv1.ThinkingMode_THINKING_MODE_CONTINUOUS_BUDGET, + Default: "1024", + } + return []model.Spec{m} + }(), + schema: &configv1.ConfigSchema{}, + wantErr: model.ErrInvalidCapabilities, + }, + { + name: "thinking mode set without default", + models: func() []model.Spec { + m := validModelSpec() + m.Thinking = model.ThinkingSpec{ + Supported: true, + Mode: modelv1.ThinkingMode_THINKING_MODE_ALWAYS_ON_ADAPTIVE, + } + return []model.Spec{m} + }(), + schema: &configv1.ConfigSchema{}, + wantErr: model.ErrInvalidCapabilities, + }, + { + name: "pricing missing currency", + models: func() []model.Spec { + m := validModelSpec() + m.Pricing.Currency = "" + return []model.Spec{m} + }(), + schema: &configv1.ConfigSchema{}, + wantErr: model.ErrInvalidPricing, + }, + { + name: "pricing without tiers and not free", + models: func() []model.Spec { + m := validModelSpec() + m.Pricing.Tiers = nil + return []model.Spec{m} + }(), + schema: &configv1.ConfigSchema{}, + wantErr: model.ErrInvalidPricing, + }, + { + name: "caching supported but tier missing cache pricing", + models: func() []model.Spec { + m := validModelSpec() + m.Caching = model.CachingSpec{Supported: true, Mode: modelv1.CachingMode_CACHING_MODE_EXPLICIT_MARKERS} + return []model.Spec{m} + }(), + schema: &configv1.ConfigSchema{}, + wantErr: model.ErrInvalidPricing, + }, + { + name: "overlapping pricing tiers", + models: func() []model.Spec { + m := validModelSpec() + m.Pricing.Tiers = []model.PricingTier{ + {InputPerMtok: 3, OutputPerMtok: 15}, + {InputPerMtok: 4, OutputPerMtok: 20}, + } + return []model.Spec{m} + }(), + schema: &configv1.ConfigSchema{}, + wantErr: model.ErrInvalidPricing, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := model.NewCapabilities(tt.models, tt.schema) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("NewCapabilities() error = %v, want wrapping %v", err, tt.wantErr) + } + }) + } +} + +func TestNewCapabilities_CachingSatisfiedTiersAreValid(t *testing.T) { + t.Parallel() + + m := validModelSpec() + m.Caching = model.CachingSpec{Supported: true, Mode: modelv1.CachingMode_CACHING_MODE_EXPLICIT_MARKERS} + m.Pricing.Tiers = []model.PricingTier{ + {InputPerMtok: 3, OutputPerMtok: 15, CacheWritePerMtok: mustFloat64(3.75), CacheReadPerMtok: mustFloat64(0.3)}, + } + + if _, err := model.NewCapabilities([]model.Spec{m}, &configv1.ConfigSchema{}); err != nil { + t.Fatalf("NewCapabilities() = %v, want nil", err) + } +} + +func TestNewCapabilities_NonOverlappingTimeBoundedTiersAreValid(t *testing.T) { + t.Parallel() + + m := validModelSpec() + feb := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + m.Pricing.Tiers = []model.PricingTier{ + {InputPerMtok: 1, OutputPerMtok: 5, EffectiveUntil: &feb}, + {InputPerMtok: 3, OutputPerMtok: 15, EffectiveFrom: &feb}, + } + + if _, err := model.NewCapabilities([]model.Spec{m}, &configv1.ConfigSchema{}); err != nil { + t.Fatalf("NewCapabilities() = %v, want nil", err) + } +} + +func TestNewCapabilities_NonOverlappingInputSizeBoundedTiersAreValid(t *testing.T) { + t.Parallel() + + m := validModelSpec() + m.Pricing.Tiers = []model.PricingTier{ + {InputPerMtok: 3, OutputPerMtok: 15, InputTokensUntil: mustInt64(200000)}, + {InputPerMtok: 6, OutputPerMtok: 15, InputTokensFrom: mustInt64(200000)}, + } + + if _, err := model.NewCapabilities([]model.Spec{m}, &configv1.ConfigSchema{}); err != nil { + t.Fatalf("NewCapabilities() = %v, want nil", err) + } +} + +func TestNewCapabilities_WithOptions(t *testing.T) { + t.Parallel() + + caps, err := model.NewCapabilities( + []model.Spec{validModelSpec()}, + &configv1.ConfigSchema{}, + model.WithSupportedHookPoints(), + model.WithSlashCommands(), + ) + if err != nil { + t.Fatalf("NewCapabilities() = %v, want nil", err) + } + if caps.SupportedHookPoints == nil && len(caps.SupportedHookPoints) != 0 { + t.Errorf("SupportedHookPoints unexpectedly non-empty") + } +} + +func TestNewCapabilities_FreeModelWithoutTiersIsValid(t *testing.T) { + t.Parallel() + + m := validModelSpec() + m.Pricing = model.Pricing{Currency: "USD", Free: true} + + if _, err := model.NewCapabilities([]model.Spec{m}, &configv1.ConfigSchema{}); err != nil { + t.Fatalf("NewCapabilities() = %v, want nil", err) + } +} diff --git a/pkg/model/convert.go b/pkg/model/convert.go new file mode 100644 index 0000000..cb6ba0e --- /dev/null +++ b/pkg/model/convert.go @@ -0,0 +1,242 @@ +package model + +import ( + "google.golang.org/protobuf/types/known/timestamppb" + + modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" +) + +// capabilitiesToProto converts c into the generated wire type +// GetCapabilities returns. c is assumed already validated (NewCapabilities +// is the only exported constructor). +func capabilitiesToProto(c *Capabilities) *modelv1.Capabilities { + models := make([]*modelv1.ModelSpec, len(c.Models)) + for i, m := range c.Models { + models[i] = modelSpecToProto(m) + } + return &modelv1.Capabilities{ + Models: models, + SlashCommands: c.SlashCommands, + ConfigSchema: c.ConfigSchema, + SupportedHookPoints: c.SupportedHookPoints, + } +} + +// capabilitiesFromProto is capabilitiesToProto's inverse. +func capabilitiesFromProto(in *modelv1.Capabilities) *Capabilities { + if in == nil { + return nil + } + models := make([]Spec, len(in.GetModels())) + for i, m := range in.GetModels() { + models[i] = modelSpecFromProto(m) + } + return &Capabilities{ + Models: models, + SlashCommands: in.GetSlashCommands(), + ConfigSchema: in.GetConfigSchema(), + SupportedHookPoints: in.GetSupportedHookPoints(), + } +} + +// modelSpecToProto converts m into the generated wire type. +func modelSpecToProto(m Spec) *modelv1.ModelSpec { + supportsParallel := m.SupportsParallelToolCalls + return &modelv1.ModelSpec{ + Id: m.ID, + ContextWindow: m.ContextWindow, + MaxOutputTokens: m.MaxOutputTokens, + SupportsToolUse: m.SupportsToolUse, + SupportsVision: m.SupportsVision, + SupportsStreaming: m.SupportsStreaming, + SupportsParallelToolCalls: &supportsParallel, + Thinking: thinkingSpecToProto(m.Thinking), + Caching: cachingSpecToProto(m.Caching), + Pricing: pricingToProto(m.Pricing), + SupportedToolChoiceModes: m.SupportedToolChoiceModes, + SupportsDocuments: m.SupportsDocuments, + } +} + +// modelSpecFromProto is modelSpecToProto's inverse. +func modelSpecFromProto(in *modelv1.ModelSpec) Spec { + if in == nil { + return Spec{} + } + return Spec{ + ID: in.GetId(), + ContextWindow: in.GetContextWindow(), + MaxOutputTokens: in.GetMaxOutputTokens(), + SupportsToolUse: in.GetSupportsToolUse(), + SupportsVision: in.GetSupportsVision(), + SupportsStreaming: in.GetSupportsStreaming(), + SupportsParallelToolCalls: in.GetSupportsParallelToolCalls(), + Thinking: thinkingSpecFromProto(in.GetThinking()), + Caching: cachingSpecFromProto(in.GetCaching()), + Pricing: pricingFromProto(in.GetPricing()), + SupportedToolChoiceModes: in.GetSupportedToolChoiceModes(), + SupportsDocuments: in.GetSupportsDocuments(), + } +} + +// thinkingSpecToProto converts t into the generated wire type. +func thinkingSpecToProto(t ThinkingSpec) *modelv1.ThinkingSpec { + out := &modelv1.ThinkingSpec{ + Supported: t.Supported, + Mode: t.Mode, + EffortLevels: t.EffortLevels, + CanDisable: t.CanDisable, + } + if t.BudgetRange != nil { + out.BudgetRange = &modelv1.ThinkingBudgetRange{ + Min: t.BudgetRange.Min, + Max: t.BudgetRange.Max, + } + } + if t.Default != "" { + def := t.Default + out.Default = &def + } + return out +} + +// thinkingSpecFromProto is thinkingSpecToProto's inverse. +func thinkingSpecFromProto(in *modelv1.ThinkingSpec) ThinkingSpec { + if in == nil { + return ThinkingSpec{} + } + out := ThinkingSpec{ + Supported: in.GetSupported(), + Mode: in.GetMode(), + EffortLevels: in.GetEffortLevels(), + CanDisable: in.GetCanDisable(), + Default: in.GetDefault(), + } + if br := in.GetBudgetRange(); br != nil { + out.BudgetRange = &ThinkingBudgetRange{Min: br.GetMin(), Max: br.GetMax()} + } + return out +} + +// cachingSpecToProto converts c into the generated wire type. +func cachingSpecToProto(c CachingSpec) *modelv1.CachingSpec { + return &modelv1.CachingSpec{ + Supported: c.Supported, + Mode: c.Mode, + KeepaliveSupported: c.KeepaliveSupported, + } +} + +// cachingSpecFromProto is cachingSpecToProto's inverse. +func cachingSpecFromProto(in *modelv1.CachingSpec) CachingSpec { + if in == nil { + return CachingSpec{} + } + return CachingSpec{ + Supported: in.GetSupported(), + Mode: in.GetMode(), + KeepaliveSupported: in.GetKeepaliveSupported(), + } +} + +// pricingToProto converts p into the generated wire type. +func pricingToProto(p Pricing) *modelv1.Pricing { + tiers := make([]*modelv1.PricingTier, len(p.Tiers)) + for i, t := range p.Tiers { + tiers[i] = pricingTierToProto(t) + } + return &modelv1.Pricing{ + Currency: p.Currency, + Free: p.Free, + Tiers: tiers, + } +} + +// pricingFromProto is pricingToProto's inverse. +func pricingFromProto(in *modelv1.Pricing) Pricing { + if in == nil { + return Pricing{} + } + tiers := make([]PricingTier, len(in.GetTiers())) + for i, t := range in.GetTiers() { + tiers[i] = pricingTierFromProto(t) + } + return Pricing{ + Currency: in.GetCurrency(), + Free: in.GetFree(), + Tiers: tiers, + } +} + +// pricingTierToProto converts t into the generated wire type. +func pricingTierToProto(t PricingTier) *modelv1.PricingTier { + out := &modelv1.PricingTier{ + InputPerMtok: t.InputPerMtok, + OutputPerMtok: t.OutputPerMtok, + } + if t.EffectiveFrom != nil { + out.EffectiveFrom = timestamppb.New(*t.EffectiveFrom) + } + if t.EffectiveUntil != nil { + out.EffectiveUntil = timestamppb.New(*t.EffectiveUntil) + } + out.CacheWritePerMtok = t.CacheWritePerMtok + out.CacheReadPerMtok = t.CacheReadPerMtok + out.BatchInputPerMtok = t.BatchInputPerMtok + out.BatchOutputPerMtok = t.BatchOutputPerMtok + out.InputTokensFrom = t.InputTokensFrom + out.InputTokensUntil = t.InputTokensUntil + return out +} + +// pricingTierFromProto is pricingTierToProto's inverse. +func pricingTierFromProto(in *modelv1.PricingTier) PricingTier { + if in == nil { + return PricingTier{} + } + out := PricingTier{ + InputPerMtok: in.GetInputPerMtok(), + OutputPerMtok: in.GetOutputPerMtok(), + CacheWritePerMtok: in.CacheWritePerMtok, + CacheReadPerMtok: in.CacheReadPerMtok, + BatchInputPerMtok: in.BatchInputPerMtok, + BatchOutputPerMtok: in.BatchOutputPerMtok, + InputTokensFrom: in.InputTokensFrom, + InputTokensUntil: in.InputTokensUntil, + } + if ef := in.GetEffectiveFrom(); ef != nil { + t := ef.AsTime() + out.EffectiveFrom = &t + } + if eu := in.GetEffectiveUntil(); eu != nil { + t := eu.AsTime() + out.EffectiveUntil = &t + } + return out +} + +// usageToProto converts u into the generated wire type carried by a +// StreamEvent Usage variant. +func usageToProto(u Usage) *modelv1.Usage { + return &modelv1.Usage{ + InputTokens: u.InputTokens, + OutputTokens: u.OutputTokens, + CacheReadTokens: u.CacheReadTokens, + CacheWriteTokens: u.CacheWriteTokens, + ReasoningTokens: u.ReasoningTokens, + } +} + +// usageFromProto is usageToProto's inverse. +func usageFromProto(in *modelv1.Usage) Usage { + if in == nil { + return Usage{} + } + return Usage{ + InputTokens: in.GetInputTokens(), + OutputTokens: in.GetOutputTokens(), + CacheReadTokens: in.CacheReadTokens, + CacheWriteTokens: in.CacheWriteTokens, + ReasoningTokens: in.ReasoningTokens, + } +} diff --git a/pkg/model/convert_test.go b/pkg/model/convert_test.go new file mode 100644 index 0000000..802eeb0 --- /dev/null +++ b/pkg/model/convert_test.go @@ -0,0 +1,322 @@ +package model_test + +import ( + "testing" + "time" + + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + model "github.com/pluggableharness/agent/pkg/model" + modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" +) + +func TestConvert_ModelSpecRoundTrip(t *testing.T) { + t.Parallel() + + from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + until := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + writePerMtok := 3.75 + readPerMtok := 0.3 + tokensFrom := int64(0) + tokensUntil := int64(200000) + + spec := model.Spec{ + ID: "claude-test", + ContextWindow: 200000, + MaxOutputTokens: 8192, + SupportsToolUse: true, + SupportsVision: true, + SupportsStreaming: true, + SupportsParallelToolCalls: true, + Thinking: model.ThinkingSpec{ + Supported: true, + Mode: modelv1.ThinkingMode_THINKING_MODE_DISCRETE_EFFORT, + EffortLevels: []string{"low", "medium", "high"}, + CanDisable: true, + Default: "medium", + }, + Caching: model.CachingSpec{ + Supported: true, + Mode: modelv1.CachingMode_CACHING_MODE_EXPLICIT_MARKERS, + KeepaliveSupported: true, + }, + Pricing: model.Pricing{ + Currency: "USD", + Tiers: []model.PricingTier{ + { + EffectiveFrom: &from, + EffectiveUntil: &until, + InputPerMtok: 3, + OutputPerMtok: 15, + CacheWritePerMtok: &writePerMtok, + CacheReadPerMtok: &readPerMtok, + InputTokensFrom: &tokensFrom, + InputTokensUntil: &tokensUntil, + }, + }, + }, + SupportedToolChoiceModes: []modelv1.ToolChoiceMode{ + modelv1.ToolChoiceMode_TOOL_CHOICE_MODE_AUTO, + modelv1.ToolChoiceMode_TOOL_CHOICE_MODE_SPECIFIC, + }, + SupportsDocuments: true, + } + + wire := model.ModelSpecToProtoForTest(spec) + back := model.ModelSpecFromProtoForTest(wire) + + if back.ID != spec.ID { + t.Errorf("ID = %q, want %q", back.ID, spec.ID) + } + if back.ContextWindow != spec.ContextWindow { + t.Errorf("ContextWindow = %d, want %d", back.ContextWindow, spec.ContextWindow) + } + if !back.SupportsParallelToolCalls { + t.Errorf("SupportsParallelToolCalls = false, want true") + } + if back.Thinking.Default != "medium" { + t.Errorf("Thinking.Default = %q, want %q", back.Thinking.Default, "medium") + } + if len(back.Thinking.EffortLevels) != 3 { + t.Errorf("len(Thinking.EffortLevels) = %d, want 3", len(back.Thinking.EffortLevels)) + } + if !back.Caching.Supported || back.Caching.Mode != modelv1.CachingMode_CACHING_MODE_EXPLICIT_MARKERS { + t.Errorf("Caching = %+v, want supported explicit_markers", back.Caching) + } + if len(back.Pricing.Tiers) != 1 { + t.Fatalf("len(Pricing.Tiers) = %d, want 1", len(back.Pricing.Tiers)) + } + tier := back.Pricing.Tiers[0] + if tier.CacheWritePerMtok == nil || *tier.CacheWritePerMtok != writePerMtok { + t.Errorf("Tiers[0].CacheWritePerMtok = %v, want %v", tier.CacheWritePerMtok, writePerMtok) + } + if tier.EffectiveFrom == nil || !tier.EffectiveFrom.Equal(from) { + t.Errorf("Tiers[0].EffectiveFrom = %v, want %v", tier.EffectiveFrom, from) + } + if tier.EffectiveUntil == nil || !tier.EffectiveUntil.Equal(until) { + t.Errorf("Tiers[0].EffectiveUntil = %v, want %v", tier.EffectiveUntil, until) + } + if tier.InputTokensFrom == nil || *tier.InputTokensFrom != tokensFrom { + t.Errorf("Tiers[0].InputTokensFrom = %v, want %v", tier.InputTokensFrom, tokensFrom) + } + if len(back.SupportedToolChoiceModes) != 2 { + t.Errorf("len(SupportedToolChoiceModes) = %d, want 2", len(back.SupportedToolChoiceModes)) + } + if !back.SupportsDocuments { + t.Errorf("SupportsDocuments = false, want true") + } +} + +func TestConvert_ModelSpecFromProtoNil(t *testing.T) { + t.Parallel() + + got := model.ModelSpecFromProtoForTest(nil) + if got.ID != "" { + t.Errorf("ModelSpecFromProtoForTest(nil).ID = %q, want empty", got.ID) + } +} + +func TestConvert_ThinkingSpecNoBudgetRange(t *testing.T) { + t.Parallel() + + in := model.ThinkingSpec{Mode: modelv1.ThinkingMode_THINKING_MODE_NONE} + wire := model.ThinkingSpecToProtoForTest(in) + if wire.GetBudgetRange() != nil { + t.Errorf("BudgetRange = %v, want nil", wire.GetBudgetRange()) + } + back := model.ThinkingSpecFromProtoForTest(wire) + if back.BudgetRange != nil { + t.Errorf("round-tripped BudgetRange = %v, want nil", back.BudgetRange) + } + + inBudget := model.ThinkingSpec{ + Supported: true, + Mode: modelv1.ThinkingMode_THINKING_MODE_CONTINUOUS_BUDGET, + BudgetRange: &model.ThinkingBudgetRange{Min: 1024, Max: 32000}, + Default: "4096", + } + wireBudget := model.ThinkingSpecToProtoForTest(inBudget) + if wireBudget.GetBudgetRange().GetMin() != 1024 || wireBudget.GetBudgetRange().GetMax() != 32000 { + t.Errorf("BudgetRange = %+v, want {1024 32000}", wireBudget.GetBudgetRange()) + } + backBudget := model.ThinkingSpecFromProtoForTest(wireBudget) + if backBudget.BudgetRange == nil || backBudget.BudgetRange.Min != 1024 || backBudget.BudgetRange.Max != 32000 { + t.Errorf("round-tripped BudgetRange = %+v, want {1024 32000}", backBudget.BudgetRange) + } +} + +func TestConvert_ThinkingSpecFromProtoNil(t *testing.T) { + t.Parallel() + + got := model.ThinkingSpecFromProtoForTest(nil) + if got.Supported { + t.Errorf("Supported = true, want false for nil input") + } +} + +func TestConvert_CachingSpecFromProtoNil(t *testing.T) { + t.Parallel() + + got := model.CachingSpecFromProtoForTest(nil) + if got.Supported { + t.Errorf("Supported = true, want false for nil input") + } +} + +func TestConvert_PricingRoundTrip_Free(t *testing.T) { + t.Parallel() + + p := model.Pricing{Currency: "USD", Free: true} + wire := model.PricingToProtoForTest(p) + if !wire.GetFree() { + t.Errorf("Free = false, want true") + } + back := model.PricingFromProtoForTest(wire) + if !back.Free || len(back.Tiers) != 0 { + t.Errorf("round-tripped Pricing = %+v, want free with no tiers", back) + } +} + +func TestConvert_PricingFromProtoNil(t *testing.T) { + t.Parallel() + + got := model.PricingFromProtoForTest(nil) + if got.Currency != "" || got.Tiers != nil { + t.Errorf("PricingFromProtoForTest(nil) = %+v, want zero value", got) + } +} + +func TestConvert_PricingTierFromProtoNil(t *testing.T) { + t.Parallel() + + got := model.PricingTierFromProtoForTest(nil) + if got.InputPerMtok != 0 { + t.Errorf("PricingTierFromProtoForTest(nil).InputPerMtok = %v, want 0", got.InputPerMtok) + } +} + +func TestConvert_UsageRoundTrip(t *testing.T) { + t.Parallel() + + cacheRead := int64(100) + cacheWrite := int64(50) + reasoning := int64(25) + u := model.Usage{ + InputTokens: 1000, + OutputTokens: 500, + CacheReadTokens: &cacheRead, + CacheWriteTokens: &cacheWrite, + ReasoningTokens: &reasoning, + } + wire := model.UsageToProtoForTest(u) + back := model.UsageFromProtoForTest(wire) + + if back.InputTokens != u.InputTokens || back.OutputTokens != u.OutputTokens { + t.Errorf("round-tripped token counts = %+v, want %+v", back, u) + } + if back.CacheReadTokens == nil || *back.CacheReadTokens != cacheRead { + t.Errorf("CacheReadTokens = %v, want %v", back.CacheReadTokens, cacheRead) + } + if back.ReasoningTokens == nil || *back.ReasoningTokens != reasoning { + t.Errorf("ReasoningTokens = %v, want %v", back.ReasoningTokens, reasoning) + } +} + +func TestConvert_UsageFromProtoNil(t *testing.T) { + t.Parallel() + + got := model.UsageFromProtoForTest(nil) + if got.InputTokens != 0 || got.CacheReadTokens != nil { + t.Errorf("UsageFromProtoForTest(nil) = %+v, want zero value", got) + } +} + +func TestConvert_CapabilitiesRoundTrip(t *testing.T) { + t.Parallel() + + caps := &model.Capabilities{ + Models: []model.Spec{{ + ID: "claude-test", + Thinking: model.ThinkingSpec{Mode: modelv1.ThinkingMode_THINKING_MODE_NONE}, + Caching: model.CachingSpec{Mode: modelv1.CachingMode_CACHING_MODE_NONE}, + Pricing: model.Pricing{Currency: "USD", Free: true}, + }}, + ConfigSchema: &configv1.ConfigSchema{}, + } + + wire := model.CapabilitiesToProtoForTest(caps) + if len(wire.GetModels()) != 1 { + t.Fatalf("len(wire.Models) = %d, want 1", len(wire.GetModels())) + } + back := model.CapabilitiesFromProtoForTest(wire) + if len(back.Models) != 1 || back.Models[0].ID != "claude-test" { + t.Errorf("round-tripped Capabilities = %+v, want one model claude-test", back) + } +} + +func TestConvert_CapabilitiesFromProtoNil(t *testing.T) { + t.Parallel() + + if got := model.CapabilitiesFromProtoForTest(nil); got != nil { + t.Errorf("CapabilitiesFromProtoForTest(nil) = %+v, want nil", got) + } +} + +func TestConvert_ModelErrorRoundTrip(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err *model.Error + }{ + { + name: "full detail", + err: &model.Error{ + Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_RATE_LIMITED, + Message: "rate limited", + Retryable: true, + RetryAfter: 30 * time.Second, + RawDetail: "429 too many requests", + }, + }, + { + name: "minimal", + err: &model.Error{ + Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, + Message: "bad request", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + wire := model.ModelErrorToProtoForTest(tt.err) + back := model.ModelErrorFromProtoForTest(wire) + + if back.Category != tt.err.Category { + t.Errorf("Category = %v, want %v", back.Category, tt.err.Category) + } + if back.Message != tt.err.Message { + t.Errorf("Message = %q, want %q", back.Message, tt.err.Message) + } + if back.Retryable != tt.err.Retryable { + t.Errorf("Retryable = %v, want %v", back.Retryable, tt.err.Retryable) + } + if back.RetryAfter != tt.err.RetryAfter { + t.Errorf("RetryAfter = %v, want %v", back.RetryAfter, tt.err.RetryAfter) + } + if back.RawDetail != tt.err.RawDetail { + t.Errorf("RawDetail = %q, want %q", back.RawDetail, tt.err.RawDetail) + } + }) + } +} + +func TestConvert_ModelErrorFromProtoNil(t *testing.T) { + t.Parallel() + + if got := model.ModelErrorFromProtoForTest(nil); got != nil { + t.Errorf("ModelErrorFromProtoForTest(nil) = %+v, want nil", got) + } +} diff --git a/pkg/model/doc.go b/pkg/model/doc.go new file mode 100644 index 0000000..95305c5 --- /dev/null +++ b/pkg/model/doc.go @@ -0,0 +1,61 @@ +// Package model is the hand-written, ergonomic SDK a model (LLM vendor) +// provider plugin author builds against, sitting on top of the generated +// pluggableharness.model.v1 types in ./proto/v1. It implements the model +// provider protocol described in docs/specifications/model/README.md, +// docs/specifications/model/protocol.md, docs/specifications/model/data-types.md, +// and docs/specifications/model/conformance.md. +// +// # Shape +// +// A plugin author implements Provider — Capabilities, Configure, and +// StreamCompletion, the three MUST RPCs +// (docs/specifications/model/conformance.md's summary matrix) — and +// optionally TokenCounter and Renderer for the SHOULD/MAY RPCs CountTokens +// and Render (docs/specifications/model/protocol.md#counttokens, +// docs/specifications/model/protocol.md#render). NewService adapts a +// Provider into the generated modelv1.ModelServiceServer, implementing +// Describe itself from a plugin.Identity (docs/specifications/model/protocol.md#describe) +// so no author code is needed for that RPC. +// +// # Domain types vs. generated types +// +// model.go defines Go-idiomatic domain types — Capabilities, Spec, +// ThinkingSpec, CachingSpec, Pricing, PricingTier, Usage — for the values a +// plugin author actually constructs by hand (typically a small, hardcoded +// model list built once at process start, +// docs/specifications/model/protocol.md#getcapabilities's "ship a built-in +// list" guidance). These trade the generated types' pointer-heavy optional +// fields (e.g. PricingTier.CacheWritePerMtok *float64) for plain Go +// zero-value-friendly fields wherever the wire's presence/absence +// distinction still needs to survive (time.Time via a pointer, not +// timestamppb.Timestamp; float64 via a pointer only where "unset" is +// itself meaningful). convert.go translates between these domain types and +// their generated modelv1 counterparts in both directions. +// +// StreamCompletionRequest and its nested types (Message, ToolDeclaration, +// GenerationParams, CacheBreakpoint, ...) are deliberately NOT mirrored +// into a parallel domain shape — Provider.StreamCompletion takes the +// generated *modelv1.StreamCompletionRequest directly. That message is +// already the canonical wire/domain shape +// (docs/specifications/model/data-types.md's "Canonical message & +// content-block schema": "the state backend's source of truth, independent +// of any one vendor's wire format"), an adapter reads every nested field +// to build its vendor's own request regardless of any intermediate shape, +// and mirroring it would only be a lossy, purely duplicative copy. +// +// StreamEvent is likewise not mirrored as a struct an author constructs +// and returns; stream.go's Sink is the domain-friendly StreamEvent +// surface instead — one method per variant (TextDelta, ThinkingDelta, +// ToolCallStart, ...), so an author never touches modelv1.StreamEvent's +// oneof directly, and Sink enforces the "exactly one terminal event" +// invariant (docs/specifications/model/data-types.md#streamevent) +// mechanically rather than by convention. +// +// # Errors +// +// errors.go's Error is the domain shape of the structured error +// taxonomy every failure crossing this plugin boundary MUST classify into +// (docs/specifications/model/conformance.md#error-taxonomy). Every +// RPC-boundary error in this package's server.go goes through +// pkg/plugin.StatusError, never a bare gRPC status. +package model diff --git a/pkg/model/errors.go b/pkg/model/errors.go new file mode 100644 index 0000000..3917eb8 --- /dev/null +++ b/pkg/model/errors.go @@ -0,0 +1,183 @@ +package model + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/durationpb" + + modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" +) + +// errorDomain is the google.rpc.ErrorInfo domain every *Error's +// StatusError carries, per .claude/rules/grpc.md's +// "most specific code, category enum in structured detail" convention. +const errorDomain = "model.pluggableharness.dev" + +// ErrStreamAlreadyTerminated is returned by every Sink method once a +// terminal event (Stop or Error) has already been sent — at most one +// terminal event may close a StreamCompletion stream, per +// docs/specifications/model/data-types.md#streamevent. +var ErrStreamAlreadyTerminated = errors.New("model: stream already terminated") + +// ErrInvalidCapabilities is wrapped by NewCapabilities when the assembled +// Capabilities value violates a MUST-level invariant from +// docs/specifications/model/data-types.md#modelspec. +var ErrInvalidCapabilities = errors.New("model: invalid capabilities") + +// ErrInvalidPricing is wrapped by validatePricing when a Spec's +// Pricing violates docs/specifications/model/data-types.md#pricing's +// tier-matching invariant. +var ErrInvalidPricing = errors.New("model: invalid pricing") + +// Error is the domain shape of the structured error taxonomy every +// StreamCompletion/Configure failure MUST classify into, per +// docs/specifications/model/conformance.md#error-taxonomy. A Provider +// returns an *Error (or a wrapped one, checked with errors.As) from +// Configure/StreamCompletion, or passes one to Sink.Error for an in-band +// stream failure; server.go converts it to the matching codes.Code via +// pkg/plugin.StatusError in both cases. +type Error struct { + // Category classifies this failure. MUST be set to something other + // than MODEL_ERROR_CATEGORY_UNSPECIFIED. + Category modelv1.ModelErrorCategory + // Message is a human-readable description of the failure. + Message string + // Retryable reports whether the kernel may retry this request as-is. + Retryable bool + // RetryAfter is how long the kernel should wait before retrying, when + // the vendor supplies one (typically alongside + // MODEL_ERROR_CATEGORY_RATE_LIMITED). SHOULD be set when available; + // zero means unset. + RetryAfter time.Duration + // RawDetail is the raw vendor-provided error code or body, for + // debugging. SHOULD be set; empty means unset. + RawDetail string +} + +// Error implements error. +func (e *Error) Error() string { + return fmt.Sprintf("model: %s: %s", categoryReason(e.Category), e.Message) +} + +// code maps e.Category to a grpc/codes.Code, per +// docs/specifications/model/conformance.md#error-taxonomy's wire-mapping +// table. +func (e *Error) code() codes.Code { + switch e.Category { + case modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED: + return codes.ResourceExhausted + case modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_RATE_LIMITED: + return codes.ResourceExhausted + case modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_OVERLOADED: + return codes.Unavailable + case modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_AUTH_ERROR: + return codes.Unauthenticated + case modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST: + return codes.InvalidArgument + case modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_CONTENT_FILTERED: + return codes.FailedPrecondition + case modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_UNKNOWN, + modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_UNSPECIFIED: + return codes.Internal + default: + // Never codes.Unknown, per conformance.md's error taxonomy table — + // an unmapped category is Internal, same as UNKNOWN/UNSPECIFIED. + return codes.Internal + } +} + +// categoryReason returns the lower_snake reason string StatusError's +// google.rpc.ErrorInfo.Reason carries for category, derived from the +// generated enum's own string representation. +func categoryReason(category modelv1.ModelErrorCategory) string { + name, ok := modelv1.ModelErrorCategory_name[int32(category)] + if !ok { + name = modelv1.ModelErrorCategory_name[int32(modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_UNKNOWN)] + } + return name +} + +// StatusError converts e into a gRPC status error via pkg/plugin.StatusError +// — the canonical shape every RPC-boundary error in this package uses. +// retryable, retry_after_seconds (when set), and raw_detail (when set) are +// carried as structured metadata. +func (e *Error) StatusError() error { + metadata := map[string]string{ + "retryable": strconv.FormatBool(e.Retryable), + } + if e.RetryAfter > 0 { + metadata["retry_after_seconds"] = strconv.FormatFloat(e.RetryAfter.Seconds(), 'f', -1, 64) + } + if e.RawDetail != "" { + metadata["raw_detail"] = e.RawDetail + } + return plugin.StatusError(e.code(), errorDomain, categoryReason(e.Category), e.Message, metadata) +} + +// toProto converts e into the wire modelv1.ModelError carried by an in-band +// StreamEvent Error variant (docs/specifications/model/data-types.md#streamevent). +func (e *Error) toProto() *modelv1.ModelError { + out := &modelv1.ModelError{ + Category: e.Category, + Message: e.Message, + Retryable: e.Retryable, + } + if e.RetryAfter > 0 { + out.RetryAfter = durationpb.New(e.RetryAfter) + } + if e.RawDetail != "" { + rawDetail := e.RawDetail + out.RawDetail = &rawDetail + } + return out +} + +// modelErrorFromProto is toProto's inverse, used by convert_test.go to +// round-trip *Error <-> *modelv1.ModelError. +func modelErrorFromProto(in *modelv1.ModelError) *Error { + if in == nil { + return nil + } + out := &Error{ + Category: in.GetCategory(), + Message: in.GetMessage(), + Retryable: in.GetRetryable(), + RawDetail: in.GetRawDetail(), + } + if d := in.GetRetryAfter(); d != nil { + out.RetryAfter = d.AsDuration() + } + return out +} + +// statusFromErr converts any error returned by a Provider (or produced +// internally by server.go) into the gRPC status error crossing the plugin +// boundary. Cancellation is checked first and always maps to a bare +// codes.Canceled status — never an application error, per +// docs/specifications/model/README.md#transport--lifecycle and +// .claude/rules/grpc.md's cancellation rule — regardless of whether err +// also happens to satisfy errors.As against *Error. An *Error +// (found via errors.As, so a wrapped one is still recognized) converts via +// its own StatusError; anything else is unmapped and becomes +// codes.Internal, never codes.Unknown, per +// docs/specifications/model/conformance.md#error-taxonomy. +func statusFromErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, context.Canceled) || status.Code(err) == codes.Canceled { + return status.Error(codes.Canceled, "model: request cancelled") + } + var modelErr *Error + if errors.As(err, &modelErr) { + return modelErr.StatusError() + } + return plugin.StatusError(codes.Internal, errorDomain, "internal", err.Error(), nil) +} diff --git a/pkg/model/errors_test.go b/pkg/model/errors_test.go new file mode 100644 index 0000000..74957df --- /dev/null +++ b/pkg/model/errors_test.go @@ -0,0 +1,157 @@ +package model_test + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" + + model "github.com/pluggableharness/agent/pkg/model" + modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" +) + +func TestModelError_Error(t *testing.T) { + t.Parallel() + + err := &model.Error{ + Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_AUTH_ERROR, + Message: "bad api key", + } + if got := err.Error(); got == "" { + t.Errorf("Error() = %q, want non-empty", got) + } +} + +func TestModelError_StatusError_CodeMapping(t *testing.T) { + t.Parallel() + + tests := []struct { + category modelv1.ModelErrorCategory + wantCode codes.Code + }{ + {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED, codes.ResourceExhausted}, + {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_RATE_LIMITED, codes.ResourceExhausted}, + {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_OVERLOADED, codes.Unavailable}, + {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_AUTH_ERROR, codes.Unauthenticated}, + {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, codes.InvalidArgument}, + {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_CONTENT_FILTERED, codes.FailedPrecondition}, + {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_UNKNOWN, codes.Internal}, + {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_UNSPECIFIED, codes.Internal}, + } + + for _, tt := range tests { + t.Run(tt.category.String(), func(t *testing.T) { + t.Parallel() + + err := &model.Error{Category: tt.category, Message: "boom"} + st, ok := grpcstatus.FromError(err.StatusError()) + if !ok { + t.Fatalf("StatusError() did not produce a *status.Status") + } + if st.Code() != tt.wantCode { + t.Errorf("code = %v, want %v", st.Code(), tt.wantCode) + } + if len(st.Details()) == 0 { + t.Errorf("StatusError() carries no structured detail, want ErrorInfo") + } + }) + } +} + +func TestModelError_StatusError_NeverUnknown(t *testing.T) { + t.Parallel() + + // An out-of-range category value (never produced by real code, but + // possible via a stray int32 cast) must still map to Internal, never + // codes.Unknown, per conformance.md's error taxonomy. + err := &model.Error{Category: modelv1.ModelErrorCategory(99), Message: "mystery"} + st, ok := grpcstatus.FromError(err.StatusError()) + if !ok { + t.Fatalf("StatusError() did not produce a *status.Status") + } + if st.Code() != codes.Internal { + t.Errorf("code = %v, want codes.Internal", st.Code()) + } + if st.Code() == codes.Unknown { + t.Errorf("code = codes.Unknown, which is never valid per conformance.md") + } +} + +func TestModelError_StatusError_Metadata(t *testing.T) { + t.Parallel() + + err := &model.Error{ + Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_RATE_LIMITED, + Message: "slow down", + Retryable: true, + RetryAfter: 2 * time.Second, + RawDetail: "vendor said 429", + } + st, ok := grpcstatus.FromError(err.StatusError()) + if !ok { + t.Fatalf("StatusError() did not produce a *status.Status") + } + if st.Message() != "slow down" { + t.Errorf("Message() = %q, want %q", st.Message(), "slow down") + } +} + +func TestStatusFromErr_Cancellation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + }{ + {"raw context.Canceled", context.Canceled}, + {"wrapped context.Canceled", fmt.Errorf("model: stream: %w", context.Canceled)}, + {"already-a-status Canceled", grpcstatus.Error(codes.Canceled, "cancelled upstream")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := model.StatusFromErrForTest(tt.err) + if grpcstatus.Code(got) != codes.Canceled { + t.Errorf("code = %v, want codes.Canceled", grpcstatus.Code(got)) + } + }) + } +} + +func TestStatusFromErr_ModelErrorWrapped(t *testing.T) { + t.Parallel() + + inner := &model.Error{ + Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_OVERLOADED, + Message: "vendor is down", + } + wrapped := fmt.Errorf("adapter: call failed: %w", inner) + + got := model.StatusFromErrForTest(wrapped) + if grpcstatus.Code(got) != codes.Unavailable { + t.Errorf("code = %v, want codes.Unavailable", grpcstatus.Code(got)) + } +} + +func TestStatusFromErr_UnmappedIsInternal(t *testing.T) { + t.Parallel() + + got := model.StatusFromErrForTest(errors.New("something unexpected")) + if grpcstatus.Code(got) != codes.Internal { + t.Errorf("code = %v, want codes.Internal", grpcstatus.Code(got)) + } +} + +func TestStatusFromErr_Nil(t *testing.T) { + t.Parallel() + + if got := model.StatusFromErrForTest(nil); got != nil { + t.Errorf("StatusFromErrForTest(nil) = %v, want nil", got) + } +} diff --git a/pkg/model/export_test.go b/pkg/model/export_test.go new file mode 100644 index 0000000..4e17fd5 --- /dev/null +++ b/pkg/model/export_test.go @@ -0,0 +1,39 @@ +package model + +// export_test.go bridges convert.go's unexported domain<->proto conversion +// functions to package model_test's black-box tests (convert_test.go), +// following the standard Go "export_test.go" pattern rather than exposing +// these as part of the package's real public API — a plugin author never +// needs to call them directly, only Provider/NewCapabilities/Sink. + +import modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" + +var ( + CapabilitiesToProtoForTest = capabilitiesToProto + CapabilitiesFromProtoForTest = capabilitiesFromProto + ModelSpecToProtoForTest = modelSpecToProto + ModelSpecFromProtoForTest = modelSpecFromProto + ThinkingSpecToProtoForTest = thinkingSpecToProto + ThinkingSpecFromProtoForTest = thinkingSpecFromProto + CachingSpecToProtoForTest = cachingSpecToProto + CachingSpecFromProtoForTest = cachingSpecFromProto + PricingToProtoForTest = pricingToProto + PricingFromProtoForTest = pricingFromProto + PricingTierToProtoForTest = pricingTierToProto + PricingTierFromProtoForTest = pricingTierFromProto + UsageToProtoForTest = usageToProto + UsageFromProtoForTest = usageFromProto + ModelErrorFromProtoForTest = modelErrorFromProto +) + +// ModelErrorToProtoForTest exposes (*Error).toProto — a method, +// rather than a bare func, so it needs a small wrapper instead of a +// var-of-func-value alias like the rest of this file. +func ModelErrorToProtoForTest(e *Error) *modelv1.ModelError { + return e.toProto() +} + +// StatusFromErrForTest exposes statusFromErr, used by errors_test.go to +// verify the cancellation short-circuit and the *Error/unmapped-error +// mapping without going through a real RPC. +var StatusFromErrForTest = statusFromErr diff --git a/pkg/model/model.go b/pkg/model/model.go new file mode 100644 index 0000000..fd69fff --- /dev/null +++ b/pkg/model/model.go @@ -0,0 +1,281 @@ +package model + +import ( + "context" + "time" + + "google.golang.org/protobuf/types/known/structpb" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// Provider is the interface a model-provider plugin author implements — the +// three MUST RPCs (docs/specifications/model/conformance.md's summary +// matrix): GetCapabilities, Configure, and StreamCompletion. +// +// A Provider MAY additionally implement TokenCounter (CountTokens, SHOULD) +// and/or Renderer (Render, MAY) — see NewService's doc comment for how +// server.go detects these. +type Provider interface { + // Capabilities returns this plugin's advertised model list and + // provider-wide declarations, per + // docs/specifications/model/protocol.md#getcapabilities. MUST be cheap + // to call repeatedly and MUST NOT require a network call to the vendor + // if avoidable — a Provider SHOULD build its model list once (e.g. a + // package-level literal assembled in a constructor) and return it here + // rather than querying the vendor per call. + Capabilities(ctx context.Context) (*Capabilities, error) + + // Configure accepts the provider's agent.hcl config block, already + // decoded from HCL/cty into a Struct by the kernel's schema-to-cty + // bridge, per docs/specifications/model/protocol.md#configure. MUST + // reject a missing required field with a structured error (a + // *Error with category invalid_request or auth_error, as + // appropriate) rather than deferring the failure to the first + // StreamCompletion call. MUST NOT echo any received secret value into + // an Emit'd event, a Render output, a log line, or an error message. + Configure(ctx context.Context, config *structpb.Struct) error + + // StreamCompletion generates one completion, writing every event + // through sink. Per docs/specifications/model/protocol.md#streamcompletion, + // a backend that does not natively stream MUST still emit its full + // response as a single terminal burst of events followed by a Stop — + // there is no separate non-streaming code path. Cancellation + // (ctx.Done(), or sink detecting the kernel closed the stream) MUST be + // treated as normal control flow: stop generating, release resources, + // and return ctx.Err() (or nil after already having sent a Stop with + // STOP_REASON_CANCELLED) rather than surfacing it as an *Error. + StreamCompletion(ctx context.Context, req *modelv1.StreamCompletionRequest, sink *Sink) error +} + +// TokenCounter is the optional interface behind CountTokens +// (docs/specifications/model/protocol.md#counttokens, SHOULD). server.go +// type-asserts a Provider against this interface at call time and returns +// codes.Unimplemented when a Provider doesn't implement it, letting the +// kernel fall back to the documented heuristic +// (docs/specifications/kernel-callbacks.md#the-fallback-heuristic) — this +// mirrors the Go standard library's optional-interface pattern (e.g. +// io.ReaderFrom) rather than adding a boolean capability flag a Provider +// must remember to keep in sync with its own method set. +type TokenCounter interface { + // CountTokens returns an exact token count for text against modelID's + // real vendor tokenizer, per + // docs/specifications/model/protocol.md#counttokens. modelID MUST be + // honored — a provider serving several models MAY use a distinct + // tokenizer per model. + CountTokens(ctx context.Context, text, modelID string) (int64, error) +} + +// Renderer is the optional interface behind Render +// (docs/specifications/model/protocol.md#render, MAY). Detected the same +// way as TokenCounter; a Provider that doesn't implement it gets +// codes.Unimplemented from server.go, and the kernel falls back to its +// generic default rendering. +type Renderer interface { + // Render decodes payload — emitted under schemaVersion — into a + // RenderTree, per docs/specifications/model/protocol.md#render and + // docs/specifications/frontend/render-tree.md#schema-versioning. A + // Provider with more than one schema_version in its history typically + // implements this by dispatching through a + // pkg/render.NewVersionRegistry. + Render(ctx context.Context, payload []byte, schemaVersion string) (*renderv1.RenderTree, error) +} + +// Capabilities is GetCapabilities' response payload, per +// docs/specifications/model/data-types.md#modelspec and +// docs/specifications/model/data-types.md#capabilitiessupported_hook_points +// — the domain mirror of modelv1.Capabilities. +type Capabilities struct { + // Models is one Spec per model this plugin can serve. MUST have + // at least one entry — NewCapabilities rejects an empty slice. + Models []Spec + // SlashCommands are prompt-expansion slash commands this provider + // contributes, declared once for the provider as a whole. MAY be + // empty. Passed through as the generated common.v1 type directly — + // see doc.go's rationale for not mirroring pass-through nested types. + SlashCommands []*commonv1.PromptExpansionSpec + // ConfigSchema is this provider's agent.hcl config schema, typically + // built with pkg/config. MUST be present. + ConfigSchema *configv1.ConfigSchema + // SupportedHookPoints declares which hook points this plugin can serve + // via HookSubscriberService.DispatchHook. MAY be empty. + SupportedHookPoints []commonv1.HookPoint +} + +// Spec describes one model this provider can serve, per +// docs/specifications/model/data-types.md#modelspec. Every field is MUST +// unless its comment says otherwise. +type Spec struct { + // ID is the vendor's exact model identifier, used to select this model + // in StreamCompletionRequest.model_id. + ID string + // ContextWindow is the model's input token budget. + ContextWindow int64 + // MaxOutputTokens is the model's maximum output tokens per response. + MaxOutputTokens int64 + // SupportsToolUse reports whether this model accepts tool + // declarations and can emit tool_use content blocks. + SupportsToolUse bool + // SupportsVision reports whether this model accepts image content + // blocks. + SupportsVision bool + // SupportsStreaming is a UX hint only — the StreamCompletion RPC shape + // is always server-streaming regardless of this value, per + // docs/specifications/model/README.md#transport--lifecycle. + SupportsStreaming bool + // SupportsParallelToolCalls reports whether this model can return + // multiple tool_use blocks in a single turn. SHOULD be set + // accurately; false means the kernel serializes tool calls for this + // model. + SupportsParallelToolCalls bool + // Thinking is this model's extended-reasoning capability. Use + // ThinkingSpec{} (Mode left at THINKING_MODE_NONE) when unsupported. + Thinking ThinkingSpec + // Caching is this model's prompt-caching capability. Use + // CachingSpec{} (Mode left at CACHING_MODE_NONE) when unsupported. + Caching CachingSpec + // Pricing is this model's cost structure. MUST be present even for a + // free model (set Pricing.Free = true). + Pricing Pricing + // SupportedToolChoiceModes declares which GenerationParams.tool_choice.mode + // values this model accepts. Empty means this model cannot constrain + // tool choice at all. + SupportedToolChoiceModes []modelv1.ToolChoiceMode + // SupportsDocuments reports whether this model accepts a + // DocumentBlock content block. + SupportsDocuments bool +} + +// ThinkingSpec describes one model's extended-reasoning capability, per +// docs/specifications/model/data-types.md#thinkingspec. +type ThinkingSpec struct { + // Supported reports whether this model has any extended-reasoning + // capability at all. + Supported bool + // Mode is which reasoning-control shape this model uses. MUST be + // THINKING_MODE_NONE when Supported is false. + Mode modelv1.ThinkingMode + // EffortLevels are the selectable effort levels, e.g. ["low","medium", + // "high","xhigh","max"]. MUST be non-empty when + // Mode == THINKING_MODE_DISCRETE_EFFORT. + EffortLevels []string + // BudgetRange is the selectable token-budget range. MUST be present + // when Mode == THINKING_MODE_CONTINUOUS_BUDGET. + BudgetRange *ThinkingBudgetRange + // CanDisable reports whether reasoning can be turned off once + // enabled. Some vendors' reasoning cannot be disabled. + CanDisable bool + // Default is the effort level (discrete_effort) or budget-token value + // (continuous_budget), as a string, the vendor applies when a request + // omits thinking config entirely. MUST be non-empty when + // Mode != THINKING_MODE_NONE. + Default string +} + +// ThinkingBudgetRange bounds the token budget a caller may request when +// ThinkingSpec.Mode is THINKING_MODE_CONTINUOUS_BUDGET. +type ThinkingBudgetRange struct { + // Min is the smallest thinking-token budget this model accepts. + Min int64 + // Max is the largest thinking-token budget this model accepts. + Max int64 +} + +// CachingSpec describes one model's prompt-caching capability, per +// docs/specifications/model/data-types.md#cachingspec. +type CachingSpec struct { + // Supported reports whether this model has any prompt-caching + // capability at all. + Supported bool + // Mode is which caching mechanic this model uses. MUST be + // CACHING_MODE_NONE when Supported is false. + Mode modelv1.CachingMode + // KeepaliveSupported reports whether this provider runs its own + // cache-keepalive loop. MUST be set (default false); cache TTL + // mechanics are vendor-specific and provider-owned, never a kernel + // loop — see the field's doc comment on the generated type. + KeepaliveSupported bool +} + +// Pricing describes one model's cost structure, per +// docs/specifications/model/data-types.md#pricing. MUST be present on +// every Spec, even a free one. +type Pricing struct { + // Currency is the pricing currency. MUST be "USD" for v1 + // (docs/specifications/model/conformance.md's open questions notes + // this as a real, if narrow, v1 constraint — no multi-currency + // aggregation exists yet). + Currency string + // Free is true for a local/free-to-run model. When true, Tiers MAY be + // empty. + Free bool + // Tiers are this model's rate tiers. MUST have at least one entry + // unless Free is true. Exactly one tier MUST match any given + // (timestamp, input_token_count) pair — see capabilities.go's + // validatePricing for the overlap check NewCapabilities performs. + Tiers []PricingTier +} + +// PricingTier is one time-bounded, input-size-bounded rate within a +// model's Pricing, per docs/specifications/model/data-types.md#pricing. +// Every *float64/*int64/*time.Time field is a pointer specifically because +// nil vs. zero is meaningful on the wire (an omitted bound is unbounded on +// that side, per the field's own doc comment) — unlike ThinkingSpec.Default +// or CachingSpec.KeepaliveSupported above, where the domain type could +// safely collapse the generated type's optionality into a plain zero-value +// field. +type PricingTier struct { + // EffectiveFrom is the moment this tier becomes active. Nil means + // "since this plugin version was published". + EffectiveFrom *time.Time + // EffectiveUntil is the moment this tier stops being active. Nil + // means "still current". + EffectiveUntil *time.Time + // InputPerMtok is the cost per million input tokens, realtime rate. + InputPerMtok float64 + // OutputPerMtok is the cost per million output tokens, realtime rate. + OutputPerMtok float64 + // CacheWritePerMtok is the cost per million cache-write tokens. MUST + // be present iff the owning Spec.Caching.Supported. + CacheWritePerMtok *float64 + // CacheReadPerMtok is the cost per million cache-read tokens. MUST be + // present iff the owning Spec.Caching.Supported. + CacheReadPerMtok *float64 + // BatchInputPerMtok is a vendor's discounted batch/async input rate, + // where one exists. MAY be present. + BatchInputPerMtok *float64 + // BatchOutputPerMtok is a vendor's discounted batch/async output + // rate, paired with BatchInputPerMtok. MAY be present. + BatchOutputPerMtok *float64 + // InputTokensFrom is the smallest accumulated-input-token count this + // tier applies to, inclusive. Nil means unbounded below. + InputTokensFrom *int64 + // InputTokensUntil is the input-token count this tier stops applying + // to, exclusive. Nil means unbounded above. + InputTokensUntil *int64 +} + +// Usage carries token accounting for one completion, per +// docs/specifications/model/data-types.md#streamevent. Passed to +// Sink.Usage. CacheReadTokens/CacheWriteTokens/ReasoningTokens are +// pointers because the vendor not reporting a distinct count (nil) is +// meaningfully different from the vendor reporting zero of that kind. +type Usage struct { + // InputTokens is the input tokens consumed by this completion. + InputTokens int64 + // OutputTokens is the output tokens produced by this completion. + OutputTokens int64 + // CacheReadTokens is tokens read from cache, when the model supports + // caching. Never also counted in InputTokens. + CacheReadTokens *int64 + // CacheWriteTokens is tokens written to cache, when the model + // supports caching. Never also counted in InputTokens. + CacheWriteTokens *int64 + // ReasoningTokens is thinking/reasoning tokens, when the vendor + // reports them as a distinct count. Never also counted in + // OutputTokens; billed at PricingTier.OutputPerMtok. + ReasoningTokens *int64 +} diff --git a/pkg/model/proto/v1/errors.pb.go b/pkg/model/proto/v1/errors.pb.go new file mode 100644 index 0000000..9b087fb --- /dev/null +++ b/pkg/model/proto/v1/errors.pb.go @@ -0,0 +1,276 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/model/v1/errors.proto + +package modelv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// ModelErrorCategory classifies every StreamCompletion/Configure +// failure, per model.md §8. A plugin MUST classify every failure into +// exactly one of these categories and MUST NOT collapse them into a +// single generic error — the kernel's routing/fallback/retry behavior +// depends on telling these apart. +type ModelErrorCategory int32 + +const ( + // Zero value. Never valid on a real ModelError; its presence on the + // wire means a caller forgot to set the field. + ModelErrorCategory_MODEL_ERROR_CATEGORY_UNSPECIFIED ModelErrorCategory = 0 + // The request (or accumulated conversation) exceeds the model's context + // window. The kernel MUST NOT blindly retry as-is. + ModelErrorCategory_MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED ModelErrorCategory = 1 + // A vendor-side rate limit was hit. The kernel retries with backoff, + // honoring retry_after if supplied. + ModelErrorCategory_MODEL_ERROR_CATEGORY_RATE_LIMITED ModelErrorCategory = 2 + // Transient vendor unavailability (5xx-equivalent). The kernel retries + // with backoff; a candidate for capability-aware fallback. + ModelErrorCategory_MODEL_ERROR_CATEGORY_OVERLOADED ModelErrorCategory = 3 + // Bad, expired, or missing credentials. The kernel MUST NOT retry or + // silently fall back; this surfaces to a human. + ModelErrorCategory_MODEL_ERROR_CATEGORY_AUTH_ERROR ModelErrorCategory = 4 + // A malformed request — almost always a kernel/adapter bug. The kernel + // MUST NOT retry as-is. + ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST ModelErrorCategory = 5 + // The vendor refused or filtered the content. Surfaced distinctly from + // a generic failure so policy/UX can handle it differently. + ModelErrorCategory_MODEL_ERROR_CATEGORY_CONTENT_FILTERED ModelErrorCategory = 6 + // Anything else. MUST include raw_detail for debugging; treated as + // non-retryable by default. + ModelErrorCategory_MODEL_ERROR_CATEGORY_UNKNOWN ModelErrorCategory = 7 +) + +// Enum value maps for ModelErrorCategory. +var ( + ModelErrorCategory_name = map[int32]string{ + 0: "MODEL_ERROR_CATEGORY_UNSPECIFIED", + 1: "MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED", + 2: "MODEL_ERROR_CATEGORY_RATE_LIMITED", + 3: "MODEL_ERROR_CATEGORY_OVERLOADED", + 4: "MODEL_ERROR_CATEGORY_AUTH_ERROR", + 5: "MODEL_ERROR_CATEGORY_INVALID_REQUEST", + 6: "MODEL_ERROR_CATEGORY_CONTENT_FILTERED", + 7: "MODEL_ERROR_CATEGORY_UNKNOWN", + } + ModelErrorCategory_value = map[string]int32{ + "MODEL_ERROR_CATEGORY_UNSPECIFIED": 0, + "MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED": 1, + "MODEL_ERROR_CATEGORY_RATE_LIMITED": 2, + "MODEL_ERROR_CATEGORY_OVERLOADED": 3, + "MODEL_ERROR_CATEGORY_AUTH_ERROR": 4, + "MODEL_ERROR_CATEGORY_INVALID_REQUEST": 5, + "MODEL_ERROR_CATEGORY_CONTENT_FILTERED": 6, + "MODEL_ERROR_CATEGORY_UNKNOWN": 7, + } +) + +func (x ModelErrorCategory) Enum() *ModelErrorCategory { + p := new(ModelErrorCategory) + *p = x + return p +} + +func (x ModelErrorCategory) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ModelErrorCategory) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_model_v1_errors_proto_enumTypes[0].Descriptor() +} + +func (ModelErrorCategory) Type() protoreflect.EnumType { + return &file_pluggableharness_model_v1_errors_proto_enumTypes[0] +} + +func (x ModelErrorCategory) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ModelErrorCategory.Descriptor instead. +func (ModelErrorCategory) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_errors_proto_rawDescGZIP(), []int{0} +} + +// ModelError is the structured error every failure crossing this +// plugin boundary carries, per model.md §8. +type ModelError struct { + state protoimpl.MessageState `protogen:"open.v1"` + // This failure's category. MUST be set. + Category ModelErrorCategory `protobuf:"varint,1,opt,name=category,proto3,enum=pluggableharness.model.v1.ModelErrorCategory" json:"category,omitempty"` + // Human-readable description of the failure. + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + // Whether the kernel may retry this request as-is. + Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"` + // How long the kernel should wait before retrying, when the vendor + // supplies one (typically alongside MODEL_ERROR_CATEGORY_RATE_LIMITED). + // SHOULD be set when available. Refines model.md §8's + // "retry_after_seconds" into the native well-known type. + RetryAfter *durationpb.Duration `protobuf:"bytes,4,opt,name=retry_after,json=retryAfter,proto3,oneof" json:"retry_after,omitempty"` + // The raw vendor-provided error code or body, for debugging. SHOULD be + // set. + RawDetail *string `protobuf:"bytes,5,opt,name=raw_detail,json=rawDetail,proto3,oneof" json:"raw_detail,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModelError) Reset() { + *x = ModelError{} + mi := &file_pluggableharness_model_v1_errors_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModelError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModelError) ProtoMessage() {} + +func (x *ModelError) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_errors_proto_msgTypes[0] + 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 ModelError.ProtoReflect.Descriptor instead. +func (*ModelError) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_errors_proto_rawDescGZIP(), []int{0} +} + +func (x *ModelError) GetCategory() ModelErrorCategory { + if x != nil { + return x.Category + } + return ModelErrorCategory_MODEL_ERROR_CATEGORY_UNSPECIFIED +} + +func (x *ModelError) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ModelError) GetRetryable() bool { + if x != nil { + return x.Retryable + } + return false +} + +func (x *ModelError) GetRetryAfter() *durationpb.Duration { + if x != nil { + return x.RetryAfter + } + return nil +} + +func (x *ModelError) GetRawDetail() string { + if x != nil && x.RawDetail != nil { + return *x.RawDetail + } + return "" +} + +var File_pluggableharness_model_v1_errors_proto protoreflect.FileDescriptor + +const file_pluggableharness_model_v1_errors_proto_rawDesc = "" + + "\n" + + "&pluggableharness/model/v1/errors.proto\x12\x19pluggableharness.model.v1\x1a\x1egoogle/protobuf/duration.proto\"\x93\x02\n" + + "\n" + + "ModelError\x12I\n" + + "\bcategory\x18\x01 \x01(\x0e2-.pluggableharness.model.v1.ModelErrorCategoryR\bcategory\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1c\n" + + "\tretryable\x18\x03 \x01(\bR\tretryable\x12?\n" + + "\vretry_after\x18\x04 \x01(\v2\x19.google.protobuf.DurationH\x00R\n" + + "retryAfter\x88\x01\x01\x12\"\n" + + "\n" + + "raw_detail\x18\x05 \x01(\tH\x01R\trawDetail\x88\x01\x01B\x0e\n" + + "\f_retry_afterB\r\n" + + "\v_raw_detail*\xd4\x02\n" + + "\x12ModelErrorCategory\x12$\n" + + " MODEL_ERROR_CATEGORY_UNSPECIFIED\x10\x00\x120\n" + + ",MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED\x10\x01\x12%\n" + + "!MODEL_ERROR_CATEGORY_RATE_LIMITED\x10\x02\x12#\n" + + "\x1fMODEL_ERROR_CATEGORY_OVERLOADED\x10\x03\x12#\n" + + "\x1fMODEL_ERROR_CATEGORY_AUTH_ERROR\x10\x04\x12(\n" + + "$MODEL_ERROR_CATEGORY_INVALID_REQUEST\x10\x05\x12)\n" + + "%MODEL_ERROR_CATEGORY_CONTENT_FILTERED\x10\x06\x12 \n" + + "\x1cMODEL_ERROR_CATEGORY_UNKNOWN\x10\aB>Z pluggableharness.model.v1.ModelErrorCategory + 2, // 1: pluggableharness.model.v1.ModelError.retry_after:type_name -> google.protobuf.Duration + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_pluggableharness_model_v1_errors_proto_init() } +func file_pluggableharness_model_v1_errors_proto_init() { + if File_pluggableharness_model_v1_errors_proto != nil { + return + } + file_pluggableharness_model_v1_errors_proto_msgTypes[0].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_model_v1_errors_proto_rawDesc), len(file_pluggableharness_model_v1_errors_proto_rawDesc)), + NumEnums: 1, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_model_v1_errors_proto_goTypes, + DependencyIndexes: file_pluggableharness_model_v1_errors_proto_depIdxs, + EnumInfos: file_pluggableharness_model_v1_errors_proto_enumTypes, + MessageInfos: file_pluggableharness_model_v1_errors_proto_msgTypes, + }.Build() + File_pluggableharness_model_v1_errors_proto = out.File + file_pluggableharness_model_v1_errors_proto_goTypes = nil + file_pluggableharness_model_v1_errors_proto_depIdxs = nil +} diff --git a/pkg/model/proto/v1/events.pb.go b/pkg/model/proto/v1/events.pb.go new file mode 100644 index 0000000..8f9398b --- /dev/null +++ b/pkg/model/proto/v1/events.pb.go @@ -0,0 +1,853 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/model/v1/events.proto + +package modelv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// StopReason classifies why a StreamCompletion ended, per model.md §4. +type StopReason int32 + +const ( + // Zero value. Never valid on a real Stop event; its presence on the + // wire means a caller forgot to set the field. + StopReason_STOP_REASON_UNSPECIFIED StopReason = 0 + // The model completed its turn normally. + StopReason_STOP_REASON_END_TURN StopReason = 1 + // The model stopped to request one or more tool invocations. + StopReason_STOP_REASON_TOOL_USE StopReason = 2 + // The model hit its output token limit before completing its turn. + StopReason_STOP_REASON_MAX_TOKENS StopReason = 3 + // The vendor's content filter stopped generation. + StopReason_STOP_REASON_CONTENT_FILTERED StopReason = 4 + // The kernel cancelled the stream (user interrupt, timeout, turn + // abort). MUST be treated by the plugin as normal control flow, never + // as an error (model.md §1, .claude/rules/grpc.md). + StopReason_STOP_REASON_CANCELLED StopReason = 5 + // The model or vendor refused to continue generating — distinct from + // STOP_REASON_CONTENT_FILTERED, which is the vendor's automated content + // filter stopping generation; REFUSAL is the model itself declining + // (e.g. a safety-trained refusal message), a semantically different + // event even though both are policy-driven stops. + StopReason_STOP_REASON_REFUSAL StopReason = 6 + // The model stopped because it generated one of + // GenerationParams.stop_sequences. Stop.matched_stop_sequence carries + // which one. + StopReason_STOP_REASON_STOP_SEQUENCE StopReason = 7 +) + +// Enum value maps for StopReason. +var ( + StopReason_name = map[int32]string{ + 0: "STOP_REASON_UNSPECIFIED", + 1: "STOP_REASON_END_TURN", + 2: "STOP_REASON_TOOL_USE", + 3: "STOP_REASON_MAX_TOKENS", + 4: "STOP_REASON_CONTENT_FILTERED", + 5: "STOP_REASON_CANCELLED", + 6: "STOP_REASON_REFUSAL", + 7: "STOP_REASON_STOP_SEQUENCE", + } + StopReason_value = map[string]int32{ + "STOP_REASON_UNSPECIFIED": 0, + "STOP_REASON_END_TURN": 1, + "STOP_REASON_TOOL_USE": 2, + "STOP_REASON_MAX_TOKENS": 3, + "STOP_REASON_CONTENT_FILTERED": 4, + "STOP_REASON_CANCELLED": 5, + "STOP_REASON_REFUSAL": 6, + "STOP_REASON_STOP_SEQUENCE": 7, + } +) + +func (x StopReason) Enum() *StopReason { + p := new(StopReason) + *p = x + return p +} + +func (x StopReason) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (StopReason) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_model_v1_events_proto_enumTypes[0].Descriptor() +} + +func (StopReason) Type() protoreflect.EnumType { + return &file_pluggableharness_model_v1_events_proto_enumTypes[0] +} + +func (x StopReason) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use StopReason.Descriptor instead. +func (StopReason) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0} +} + +// StreamEvent is one message in the stream StreamCompletion returns, per +// model.md §4. Exactly one variant is set. +type StreamEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *StreamEvent_TextDelta_ + // *StreamEvent_ThinkingDelta_ + // *StreamEvent_ThinkingSignature_ + // *StreamEvent_ToolCallStart_ + // *StreamEvent_ToolCallDelta_ + // *StreamEvent_ToolCallDone_ + // *StreamEvent_Usage + // *StreamEvent_Stop_ + // *StreamEvent_Error_ + Event isStreamEvent_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamEvent) Reset() { + *x = StreamEvent{} + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamEvent) ProtoMessage() {} + +func (x *StreamEvent) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[0] + 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 StreamEvent.ProtoReflect.Descriptor instead. +func (*StreamEvent) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0} +} + +func (x *StreamEvent) GetEvent() isStreamEvent_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *StreamEvent) GetTextDelta() *StreamEvent_TextDelta { + if x != nil { + if x, ok := x.Event.(*StreamEvent_TextDelta_); ok { + return x.TextDelta + } + } + return nil +} + +func (x *StreamEvent) GetThinkingDelta() *StreamEvent_ThinkingDelta { + if x != nil { + if x, ok := x.Event.(*StreamEvent_ThinkingDelta_); ok { + return x.ThinkingDelta + } + } + return nil +} + +func (x *StreamEvent) GetThinkingSignature() *StreamEvent_ThinkingSignature { + if x != nil { + if x, ok := x.Event.(*StreamEvent_ThinkingSignature_); ok { + return x.ThinkingSignature + } + } + return nil +} + +func (x *StreamEvent) GetToolCallStart() *StreamEvent_ToolCallStart { + if x != nil { + if x, ok := x.Event.(*StreamEvent_ToolCallStart_); ok { + return x.ToolCallStart + } + } + return nil +} + +func (x *StreamEvent) GetToolCallDelta() *StreamEvent_ToolCallDelta { + if x != nil { + if x, ok := x.Event.(*StreamEvent_ToolCallDelta_); ok { + return x.ToolCallDelta + } + } + return nil +} + +func (x *StreamEvent) GetToolCallDone() *StreamEvent_ToolCallDone { + if x != nil { + if x, ok := x.Event.(*StreamEvent_ToolCallDone_); ok { + return x.ToolCallDone + } + } + return nil +} + +func (x *StreamEvent) GetUsage() *Usage { + if x != nil { + if x, ok := x.Event.(*StreamEvent_Usage); ok { + return x.Usage + } + } + return nil +} + +func (x *StreamEvent) GetStop() *StreamEvent_Stop { + if x != nil { + if x, ok := x.Event.(*StreamEvent_Stop_); ok { + return x.Stop + } + } + return nil +} + +func (x *StreamEvent) GetError() *StreamEvent_Error { + if x != nil { + if x, ok := x.Event.(*StreamEvent_Error_); ok { + return x.Error + } + } + return nil +} + +type isStreamEvent_Event interface { + isStreamEvent_Event() +} + +type StreamEvent_TextDelta_ struct { + // An incremental fragment of assistant text output. + TextDelta *StreamEvent_TextDelta `protobuf:"bytes,1,opt,name=text_delta,json=textDelta,proto3,oneof"` +} + +type StreamEvent_ThinkingDelta_ struct { + // An incremental fragment of the model's reasoning output. + ThinkingDelta *StreamEvent_ThinkingDelta `protobuf:"bytes,2,opt,name=thinking_delta,json=thinkingDelta,proto3,oneof"` +} + +type StreamEvent_ThinkingSignature_ struct { + // The vendor's opaque integrity token for the reasoning just emitted. + ThinkingSignature *StreamEvent_ThinkingSignature `protobuf:"bytes,3,opt,name=thinking_signature,json=thinkingSignature,proto3,oneof"` +} + +type StreamEvent_ToolCallStart_ struct { + // The model has begun requesting a tool invocation. + ToolCallStart *StreamEvent_ToolCallStart `protobuf:"bytes,4,opt,name=tool_call_start,json=toolCallStart,proto3,oneof"` +} + +type StreamEvent_ToolCallDelta_ struct { + // An incremental fragment of a tool call's arguments. + ToolCallDelta *StreamEvent_ToolCallDelta `protobuf:"bytes,5,opt,name=tool_call_delta,json=toolCallDelta,proto3,oneof"` +} + +type StreamEvent_ToolCallDone_ struct { + // A tool call's arguments are complete. + ToolCallDone *StreamEvent_ToolCallDone `protobuf:"bytes,6,opt,name=tool_call_done,json=toolCallDone,proto3,oneof"` +} + +type StreamEvent_Usage struct { + // Token accounting for this completion. + Usage *Usage `protobuf:"bytes,7,opt,name=usage,proto3,oneof"` +} + +type StreamEvent_Stop_ struct { + // The completion has ended. + Stop *StreamEvent_Stop `protobuf:"bytes,8,opt,name=stop,proto3,oneof"` +} + +type StreamEvent_Error_ struct { + // The completion failed. + Error *StreamEvent_Error `protobuf:"bytes,9,opt,name=error,proto3,oneof"` +} + +func (*StreamEvent_TextDelta_) isStreamEvent_Event() {} + +func (*StreamEvent_ThinkingDelta_) isStreamEvent_Event() {} + +func (*StreamEvent_ThinkingSignature_) isStreamEvent_Event() {} + +func (*StreamEvent_ToolCallStart_) isStreamEvent_Event() {} + +func (*StreamEvent_ToolCallDelta_) isStreamEvent_Event() {} + +func (*StreamEvent_ToolCallDone_) isStreamEvent_Event() {} + +func (*StreamEvent_Usage) isStreamEvent_Event() {} + +func (*StreamEvent_Stop_) isStreamEvent_Event() {} + +func (*StreamEvent_Error_) isStreamEvent_Event() {} + +// TextDelta carries one incremental fragment of assistant text output. +// MUST be supported by every plugin, both directions (model.md §5). +type StreamEvent_TextDelta struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The text fragment. + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamEvent_TextDelta) Reset() { + *x = StreamEvent_TextDelta{} + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamEvent_TextDelta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamEvent_TextDelta) ProtoMessage() {} + +func (x *StreamEvent_TextDelta) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[1] + 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 StreamEvent_TextDelta.ProtoReflect.Descriptor instead. +func (*StreamEvent_TextDelta) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 0} +} + +func (x *StreamEvent_TextDelta) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +// ThinkingDelta carries one incremental fragment of the model's +// reasoning output. Only emitted when the target model's +// ThinkingSpec.supported is true. +type StreamEvent_ThinkingDelta struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The reasoning-text fragment. + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamEvent_ThinkingDelta) Reset() { + *x = StreamEvent_ThinkingDelta{} + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamEvent_ThinkingDelta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamEvent_ThinkingDelta) ProtoMessage() {} + +func (x *StreamEvent_ThinkingDelta) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[2] + 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 StreamEvent_ThinkingDelta.ProtoReflect.Descriptor instead. +func (*StreamEvent_ThinkingDelta) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 1} +} + +func (x *StreamEvent_ThinkingDelta) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +// ThinkingSignature carries the vendor's opaque integrity token for the +// reasoning block just completed. MUST be emitted if the vendor's +// thinking blocks carry an integrity signature (model.md §4/§5); the +// kernel MUST store and round-trip this verbatim, never inspecting or +// reformatting it, into ContentBlock's ThinkingBlock.signature. +type StreamEvent_ThinkingSignature struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The opaque, vendor-specific signature bytes. + Signature []byte `protobuf:"bytes,1,opt,name=signature,proto3" json:"signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamEvent_ThinkingSignature) Reset() { + *x = StreamEvent_ThinkingSignature{} + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamEvent_ThinkingSignature) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamEvent_ThinkingSignature) ProtoMessage() {} + +func (x *StreamEvent_ThinkingSignature) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[3] + 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 StreamEvent_ThinkingSignature.ProtoReflect.Descriptor instead. +func (*StreamEvent_ThinkingSignature) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 2} +} + +func (x *StreamEvent_ThinkingSignature) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +// ToolCallStart announces the model has begun requesting a tool +// invocation. +type StreamEvent_ToolCallStart struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Correlation id for the matching ToolCallDelta/ToolCallDone events + // and the resulting ToolUseBlock.id. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // The tool's declared name (ToolDeclaration.name). + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamEvent_ToolCallStart) Reset() { + *x = StreamEvent_ToolCallStart{} + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamEvent_ToolCallStart) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamEvent_ToolCallStart) ProtoMessage() {} + +func (x *StreamEvent_ToolCallStart) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[4] + 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 StreamEvent_ToolCallStart.ProtoReflect.Descriptor instead. +func (*StreamEvent_ToolCallStart) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 3} +} + +func (x *StreamEvent_ToolCallStart) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *StreamEvent_ToolCallStart) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +// ToolCallDelta carries one incremental fragment of a tool call's +// arguments, accumulated by the kernel across deltas into the final +// parsed JSON. +type StreamEvent_ToolCallDelta struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The id from the matching ToolCallStart. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // A partial-JSON fragment of the call's arguments. + ArgumentsFragment string `protobuf:"bytes,2,opt,name=arguments_fragment,json=argumentsFragment,proto3" json:"arguments_fragment,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamEvent_ToolCallDelta) Reset() { + *x = StreamEvent_ToolCallDelta{} + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamEvent_ToolCallDelta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamEvent_ToolCallDelta) ProtoMessage() {} + +func (x *StreamEvent_ToolCallDelta) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_events_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 StreamEvent_ToolCallDelta.ProtoReflect.Descriptor instead. +func (*StreamEvent_ToolCallDelta) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 4} +} + +func (x *StreamEvent_ToolCallDelta) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *StreamEvent_ToolCallDelta) GetArgumentsFragment() string { + if x != nil { + return x.ArgumentsFragment + } + return "" +} + +// ToolCallDone signals a tool call's arguments are complete and ready +// for the kernel to parse and dispatch. +type StreamEvent_ToolCallDone struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The id from the matching ToolCallStart. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamEvent_ToolCallDone) Reset() { + *x = StreamEvent_ToolCallDone{} + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamEvent_ToolCallDone) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamEvent_ToolCallDone) ProtoMessage() {} + +func (x *StreamEvent_ToolCallDone) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_events_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 StreamEvent_ToolCallDone.ProtoReflect.Descriptor instead. +func (*StreamEvent_ToolCallDone) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 5} +} + +func (x *StreamEvent_ToolCallDone) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// Stop signals the completion has ended. +type StreamEvent_Stop struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Why the completion ended. + Reason StopReason `protobuf:"varint,1,opt,name=reason,proto3,enum=pluggableharness.model.v1.StopReason" json:"reason,omitempty"` + // Which GenerationParams.stop_sequences entry was matched. Set iff + // reason == STOP_REASON_STOP_SEQUENCE; MUST be omitted for every + // other reason. + MatchedStopSequence *string `protobuf:"bytes,2,opt,name=matched_stop_sequence,json=matchedStopSequence,proto3,oneof" json:"matched_stop_sequence,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamEvent_Stop) Reset() { + *x = StreamEvent_Stop{} + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamEvent_Stop) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamEvent_Stop) ProtoMessage() {} + +func (x *StreamEvent_Stop) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[7] + 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 StreamEvent_Stop.ProtoReflect.Descriptor instead. +func (*StreamEvent_Stop) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 6} +} + +func (x *StreamEvent_Stop) GetReason() StopReason { + if x != nil { + return x.Reason + } + return StopReason_STOP_REASON_UNSPECIFIED +} + +func (x *StreamEvent_Stop) GetMatchedStopSequence() string { + if x != nil && x.MatchedStopSequence != nil { + return *x.MatchedStopSequence + } + return "" +} + +// Error signals the completion failed. +type StreamEvent_Error struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The structured error, classified per model.md §8. + Error *ModelError `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamEvent_Error) Reset() { + *x = StreamEvent_Error{} + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamEvent_Error) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamEvent_Error) ProtoMessage() {} + +func (x *StreamEvent_Error) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[8] + 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 StreamEvent_Error.ProtoReflect.Descriptor instead. +func (*StreamEvent_Error) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 7} +} + +func (x *StreamEvent_Error) GetError() *ModelError { + if x != nil { + return x.Error + } + return nil +} + +var File_pluggableharness_model_v1_events_proto protoreflect.FileDescriptor + +const file_pluggableharness_model_v1_events_proto_rawDesc = "" + + "\n" + + "&pluggableharness/model/v1/events.proto\x12\x19pluggableharness.model.v1\x1a&pluggableharness/model/v1/errors.proto\x1a%pluggableharness/model/v1/types.proto\"\x92\n" + + "\n" + + "\vStreamEvent\x12Q\n" + + "\n" + + "text_delta\x18\x01 \x01(\v20.pluggableharness.model.v1.StreamEvent.TextDeltaH\x00R\ttextDelta\x12]\n" + + "\x0ethinking_delta\x18\x02 \x01(\v24.pluggableharness.model.v1.StreamEvent.ThinkingDeltaH\x00R\rthinkingDelta\x12i\n" + + "\x12thinking_signature\x18\x03 \x01(\v28.pluggableharness.model.v1.StreamEvent.ThinkingSignatureH\x00R\x11thinkingSignature\x12^\n" + + "\x0ftool_call_start\x18\x04 \x01(\v24.pluggableharness.model.v1.StreamEvent.ToolCallStartH\x00R\rtoolCallStart\x12^\n" + + "\x0ftool_call_delta\x18\x05 \x01(\v24.pluggableharness.model.v1.StreamEvent.ToolCallDeltaH\x00R\rtoolCallDelta\x12[\n" + + "\x0etool_call_done\x18\x06 \x01(\v23.pluggableharness.model.v1.StreamEvent.ToolCallDoneH\x00R\ftoolCallDone\x128\n" + + "\x05usage\x18\a \x01(\v2 .pluggableharness.model.v1.UsageH\x00R\x05usage\x12A\n" + + "\x04stop\x18\b \x01(\v2+.pluggableharness.model.v1.StreamEvent.StopH\x00R\x04stop\x12D\n" + + "\x05error\x18\t \x01(\v2,.pluggableharness.model.v1.StreamEvent.ErrorH\x00R\x05error\x1a\x1f\n" + + "\tTextDelta\x12\x12\n" + + "\x04text\x18\x01 \x01(\tR\x04text\x1a#\n" + + "\rThinkingDelta\x12\x12\n" + + "\x04text\x18\x01 \x01(\tR\x04text\x1a1\n" + + "\x11ThinkingSignature\x12\x1c\n" + + "\tsignature\x18\x01 \x01(\fR\tsignature\x1a3\n" + + "\rToolCallStart\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x1aN\n" + + "\rToolCallDelta\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12-\n" + + "\x12arguments_fragment\x18\x02 \x01(\tR\x11argumentsFragment\x1a\x1e\n" + + "\fToolCallDone\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x1a\x98\x01\n" + + "\x04Stop\x12=\n" + + "\x06reason\x18\x01 \x01(\x0e2%.pluggableharness.model.v1.StopReasonR\x06reason\x127\n" + + "\x15matched_stop_sequence\x18\x02 \x01(\tH\x00R\x13matchedStopSequence\x88\x01\x01B\x18\n" + + "\x16_matched_stop_sequence\x1aD\n" + + "\x05Error\x12;\n" + + "\x05error\x18\x01 \x01(\v2%.pluggableharness.model.v1.ModelErrorR\x05errorB\a\n" + + "\x05event*\xee\x01\n" + + "\n" + + "StopReason\x12\x1b\n" + + "\x17STOP_REASON_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14STOP_REASON_END_TURN\x10\x01\x12\x18\n" + + "\x14STOP_REASON_TOOL_USE\x10\x02\x12\x1a\n" + + "\x16STOP_REASON_MAX_TOKENS\x10\x03\x12 \n" + + "\x1cSTOP_REASON_CONTENT_FILTERED\x10\x04\x12\x19\n" + + "\x15STOP_REASON_CANCELLED\x10\x05\x12\x17\n" + + "\x13STOP_REASON_REFUSAL\x10\x06\x12\x1d\n" + + "\x19STOP_REASON_STOP_SEQUENCE\x10\aB>Z pluggableharness.model.v1.StreamEvent.TextDelta + 3, // 1: pluggableharness.model.v1.StreamEvent.thinking_delta:type_name -> pluggableharness.model.v1.StreamEvent.ThinkingDelta + 4, // 2: pluggableharness.model.v1.StreamEvent.thinking_signature:type_name -> pluggableharness.model.v1.StreamEvent.ThinkingSignature + 5, // 3: pluggableharness.model.v1.StreamEvent.tool_call_start:type_name -> pluggableharness.model.v1.StreamEvent.ToolCallStart + 6, // 4: pluggableharness.model.v1.StreamEvent.tool_call_delta:type_name -> pluggableharness.model.v1.StreamEvent.ToolCallDelta + 7, // 5: pluggableharness.model.v1.StreamEvent.tool_call_done:type_name -> pluggableharness.model.v1.StreamEvent.ToolCallDone + 10, // 6: pluggableharness.model.v1.StreamEvent.usage:type_name -> pluggableharness.model.v1.Usage + 8, // 7: pluggableharness.model.v1.StreamEvent.stop:type_name -> pluggableharness.model.v1.StreamEvent.Stop + 9, // 8: pluggableharness.model.v1.StreamEvent.error:type_name -> pluggableharness.model.v1.StreamEvent.Error + 0, // 9: pluggableharness.model.v1.StreamEvent.Stop.reason:type_name -> pluggableharness.model.v1.StopReason + 11, // 10: pluggableharness.model.v1.StreamEvent.Error.error:type_name -> pluggableharness.model.v1.ModelError + 11, // [11:11] is the sub-list for method output_type + 11, // [11:11] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_pluggableharness_model_v1_events_proto_init() } +func file_pluggableharness_model_v1_events_proto_init() { + if File_pluggableharness_model_v1_events_proto != nil { + return + } + file_pluggableharness_model_v1_errors_proto_init() + file_pluggableharness_model_v1_types_proto_init() + file_pluggableharness_model_v1_events_proto_msgTypes[0].OneofWrappers = []any{ + (*StreamEvent_TextDelta_)(nil), + (*StreamEvent_ThinkingDelta_)(nil), + (*StreamEvent_ThinkingSignature_)(nil), + (*StreamEvent_ToolCallStart_)(nil), + (*StreamEvent_ToolCallDelta_)(nil), + (*StreamEvent_ToolCallDone_)(nil), + (*StreamEvent_Usage)(nil), + (*StreamEvent_Stop_)(nil), + (*StreamEvent_Error_)(nil), + } + file_pluggableharness_model_v1_events_proto_msgTypes[7].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_model_v1_events_proto_rawDesc), len(file_pluggableharness_model_v1_events_proto_rawDesc)), + NumEnums: 1, + NumMessages: 9, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_model_v1_events_proto_goTypes, + DependencyIndexes: file_pluggableharness_model_v1_events_proto_depIdxs, + EnumInfos: file_pluggableharness_model_v1_events_proto_enumTypes, + MessageInfos: file_pluggableharness_model_v1_events_proto_msgTypes, + }.Build() + File_pluggableharness_model_v1_events_proto = out.File + file_pluggableharness_model_v1_events_proto_goTypes = nil + file_pluggableharness_model_v1_events_proto_depIdxs = nil +} diff --git a/pkg/model/proto/v1/model.pb.go b/pkg/model/proto/v1/model.pb.go deleted file mode 100644 index cddb71c..0000000 --- a/pkg/model/proto/v1/model.pb.go +++ /dev/null @@ -1,3504 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: pluggableharness/model/v1/model.proto - -// Package pluggableharness.model.v1 defines the model (LLM vendor) provider -// plugin protocol described in specifications/model.md — see -// .claude/rules/proto.md — plus the two distinct model-identity shapes used -// across the other specs. The identity shapes are deliberately NOT unified -// into one message: ModelTarget is a rich "what am I generating context -// for" descriptor (context.md §4, memory.md §6); ModelRef is a narrow -// "which model's tokenizer" selector (kernel-callbacks.md §2). Merging them -// would force every CountTokens caller to populate fields it doesn't have -// and doesn't need. - -package modelv1 - -import ( - v12 "github.com/pluggableharness/agent/pkg/common/proto/v1" - v11 "github.com/pluggableharness/agent/pkg/config/proto/v1" - v13 "github.com/pluggableharness/agent/pkg/content/proto/v1" - v15 "github.com/pluggableharness/agent/pkg/render/proto/v1" - v14 "github.com/pluggableharness/agent/pkg/schema/proto/v1" - v1 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - durationpb "google.golang.org/protobuf/types/known/durationpb" - structpb "google.golang.org/protobuf/types/known/structpb" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// ThinkingMode enumerates the shapes of extended-reasoning control found -// across researched vendors (model.md §2) — a plain supports_thinking -// bool would lose information the kernel needs to build a correct -// request, since some vendors (e.g. Anthropic) expose more than one mode -// across their own model lineup. -type ThinkingMode int32 - -const ( - // Zero value. Never valid when ThinkingSpec.supported is true; its - // presence on the wire means a caller forgot to set the field. - ThinkingMode_THINKING_MODE_UNSPECIFIED ThinkingMode = 0 - // The model has no extended-reasoning capability. Pairs with - // ThinkingSpec.supported == false. - ThinkingMode_THINKING_MODE_NONE ThinkingMode = 1 - // The model always reasons, adaptively, with no caller-selectable - // effort level or budget. - ThinkingMode_THINKING_MODE_ALWAYS_ON_ADAPTIVE ThinkingMode = 2 - // The caller selects one of a fixed set of named effort levels - // (ThinkingSpec.effort_levels). - ThinkingMode_THINKING_MODE_DISCRETE_EFFORT ThinkingMode = 3 - // The caller selects a token budget within ThinkingSpec.budget_range. - ThinkingMode_THINKING_MODE_CONTINUOUS_BUDGET ThinkingMode = 4 -) - -// Enum value maps for ThinkingMode. -var ( - ThinkingMode_name = map[int32]string{ - 0: "THINKING_MODE_UNSPECIFIED", - 1: "THINKING_MODE_NONE", - 2: "THINKING_MODE_ALWAYS_ON_ADAPTIVE", - 3: "THINKING_MODE_DISCRETE_EFFORT", - 4: "THINKING_MODE_CONTINUOUS_BUDGET", - } - ThinkingMode_value = map[string]int32{ - "THINKING_MODE_UNSPECIFIED": 0, - "THINKING_MODE_NONE": 1, - "THINKING_MODE_ALWAYS_ON_ADAPTIVE": 2, - "THINKING_MODE_DISCRETE_EFFORT": 3, - "THINKING_MODE_CONTINUOUS_BUDGET": 4, - } -) - -func (x ThinkingMode) Enum() *ThinkingMode { - p := new(ThinkingMode) - *p = x - return p -} - -func (x ThinkingMode) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (ThinkingMode) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_model_v1_model_proto_enumTypes[0].Descriptor() -} - -func (ThinkingMode) Type() protoreflect.EnumType { - return &file_pluggableharness_model_v1_model_proto_enumTypes[0] -} - -func (x ThinkingMode) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use ThinkingMode.Descriptor instead. -func (ThinkingMode) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{0} -} - -// CachingMode enumerates the prompt-caching mechanics found across -// researched vendors (model.md §2). -type CachingMode int32 - -const ( - // Zero value. Never valid when CachingSpec.supported is true; its - // presence on the wire means a caller forgot to set the field. - CachingMode_CACHING_MODE_UNSPECIFIED CachingMode = 0 - // The model has no prompt-caching capability. Pairs with - // CachingSpec.supported == false. - CachingMode_CACHING_MODE_NONE CachingMode = 1 - // The caller must place cache breakpoints on content blocks explicitly - // (Anthropic/Mistral-style). - CachingMode_CACHING_MODE_EXPLICIT_MARKERS CachingMode = 2 - // The vendor applies caching transparently above a token threshold, no - // caller action required. - CachingMode_CACHING_MODE_IMPLICIT_AUTOMATIC CachingMode = 3 -) - -// Enum value maps for CachingMode. -var ( - CachingMode_name = map[int32]string{ - 0: "CACHING_MODE_UNSPECIFIED", - 1: "CACHING_MODE_NONE", - 2: "CACHING_MODE_EXPLICIT_MARKERS", - 3: "CACHING_MODE_IMPLICIT_AUTOMATIC", - } - CachingMode_value = map[string]int32{ - "CACHING_MODE_UNSPECIFIED": 0, - "CACHING_MODE_NONE": 1, - "CACHING_MODE_EXPLICIT_MARKERS": 2, - "CACHING_MODE_IMPLICIT_AUTOMATIC": 3, - } -) - -func (x CachingMode) Enum() *CachingMode { - p := new(CachingMode) - *p = x - return p -} - -func (x CachingMode) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (CachingMode) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_model_v1_model_proto_enumTypes[1].Descriptor() -} - -func (CachingMode) Type() protoreflect.EnumType { - return &file_pluggableharness_model_v1_model_proto_enumTypes[1] -} - -func (x CachingMode) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use CachingMode.Descriptor instead. -func (CachingMode) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{1} -} - -// ToolChoiceMode enumerates the tool-invocation constraint shapes found -// across researched vendors, per the same "declare precisely, don't -// collapse to a bool" reasoning as ThinkingMode/CachingMode above. -type ToolChoiceMode int32 - -const ( - // Zero value. Never valid on a real ToolChoice; its presence on the - // wire means a caller forgot to set the field. - ToolChoiceMode_TOOL_CHOICE_MODE_UNSPECIFIED ToolChoiceMode = 0 - // The model decides freely whether and which tool to call. Equivalent - // to omitting GenerationParams.tool_choice entirely. - ToolChoiceMode_TOOL_CHOICE_MODE_AUTO ToolChoiceMode = 1 - // The model MUST call some tool this turn, but may pick which one. - ToolChoiceMode_TOOL_CHOICE_MODE_ANY ToolChoiceMode = 2 - // The model MUST NOT call any tool this turn, even if tools were - // declared. - ToolChoiceMode_TOOL_CHOICE_MODE_NONE ToolChoiceMode = 3 - // The model MUST call the specific tool named in ToolChoice.tool_name. - ToolChoiceMode_TOOL_CHOICE_MODE_SPECIFIC ToolChoiceMode = 4 -) - -// Enum value maps for ToolChoiceMode. -var ( - ToolChoiceMode_name = map[int32]string{ - 0: "TOOL_CHOICE_MODE_UNSPECIFIED", - 1: "TOOL_CHOICE_MODE_AUTO", - 2: "TOOL_CHOICE_MODE_ANY", - 3: "TOOL_CHOICE_MODE_NONE", - 4: "TOOL_CHOICE_MODE_SPECIFIC", - } - ToolChoiceMode_value = map[string]int32{ - "TOOL_CHOICE_MODE_UNSPECIFIED": 0, - "TOOL_CHOICE_MODE_AUTO": 1, - "TOOL_CHOICE_MODE_ANY": 2, - "TOOL_CHOICE_MODE_NONE": 3, - "TOOL_CHOICE_MODE_SPECIFIC": 4, - } -) - -func (x ToolChoiceMode) Enum() *ToolChoiceMode { - p := new(ToolChoiceMode) - *p = x - return p -} - -func (x ToolChoiceMode) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (ToolChoiceMode) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_model_v1_model_proto_enumTypes[2].Descriptor() -} - -func (ToolChoiceMode) Type() protoreflect.EnumType { - return &file_pluggableharness_model_v1_model_proto_enumTypes[2] -} - -func (x ToolChoiceMode) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use ToolChoiceMode.Descriptor instead. -func (ToolChoiceMode) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{2} -} - -// StopReason classifies why a StreamCompletion ended, per model.md §4. -type StopReason int32 - -const ( - // Zero value. Never valid on a real Stop event; its presence on the - // wire means a caller forgot to set the field. - StopReason_STOP_REASON_UNSPECIFIED StopReason = 0 - // The model completed its turn normally. - StopReason_STOP_REASON_END_TURN StopReason = 1 - // The model stopped to request one or more tool invocations. - StopReason_STOP_REASON_TOOL_USE StopReason = 2 - // The model hit its output token limit before completing its turn. - StopReason_STOP_REASON_MAX_TOKENS StopReason = 3 - // The vendor's content filter stopped generation. - StopReason_STOP_REASON_CONTENT_FILTERED StopReason = 4 - // The kernel cancelled the stream (user interrupt, timeout, turn - // abort). MUST be treated by the plugin as normal control flow, never - // as an error (model.md §1, .claude/rules/grpc.md). - StopReason_STOP_REASON_CANCELLED StopReason = 5 - // The model or vendor refused to continue generating — distinct from - // STOP_REASON_CONTENT_FILTERED, which is the vendor's automated content - // filter stopping generation; REFUSAL is the model itself declining - // (e.g. a safety-trained refusal message), a semantically different - // event even though both are policy-driven stops. - StopReason_STOP_REASON_REFUSAL StopReason = 6 - // The model stopped because it generated one of - // GenerationParams.stop_sequences. Stop.matched_stop_sequence carries - // which one. - StopReason_STOP_REASON_STOP_SEQUENCE StopReason = 7 -) - -// Enum value maps for StopReason. -var ( - StopReason_name = map[int32]string{ - 0: "STOP_REASON_UNSPECIFIED", - 1: "STOP_REASON_END_TURN", - 2: "STOP_REASON_TOOL_USE", - 3: "STOP_REASON_MAX_TOKENS", - 4: "STOP_REASON_CONTENT_FILTERED", - 5: "STOP_REASON_CANCELLED", - 6: "STOP_REASON_REFUSAL", - 7: "STOP_REASON_STOP_SEQUENCE", - } - StopReason_value = map[string]int32{ - "STOP_REASON_UNSPECIFIED": 0, - "STOP_REASON_END_TURN": 1, - "STOP_REASON_TOOL_USE": 2, - "STOP_REASON_MAX_TOKENS": 3, - "STOP_REASON_CONTENT_FILTERED": 4, - "STOP_REASON_CANCELLED": 5, - "STOP_REASON_REFUSAL": 6, - "STOP_REASON_STOP_SEQUENCE": 7, - } -) - -func (x StopReason) Enum() *StopReason { - p := new(StopReason) - *p = x - return p -} - -func (x StopReason) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (StopReason) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_model_v1_model_proto_enumTypes[3].Descriptor() -} - -func (StopReason) Type() protoreflect.EnumType { - return &file_pluggableharness_model_v1_model_proto_enumTypes[3] -} - -func (x StopReason) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use StopReason.Descriptor instead. -func (StopReason) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{3} -} - -// ModelErrorCategory classifies every StreamCompletion/Configure -// failure, per model.md §8. A plugin MUST classify every failure into -// exactly one of these categories and MUST NOT collapse them into a -// single generic error — the kernel's routing/fallback/retry behavior -// depends on telling these apart. -type ModelErrorCategory int32 - -const ( - // Zero value. Never valid on a real ModelError; its presence on the - // wire means a caller forgot to set the field. - ModelErrorCategory_MODEL_ERROR_CATEGORY_UNSPECIFIED ModelErrorCategory = 0 - // The request (or accumulated conversation) exceeds the model's context - // window. The kernel MUST NOT blindly retry as-is. - ModelErrorCategory_MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED ModelErrorCategory = 1 - // A vendor-side rate limit was hit. The kernel retries with backoff, - // honoring retry_after if supplied. - ModelErrorCategory_MODEL_ERROR_CATEGORY_RATE_LIMITED ModelErrorCategory = 2 - // Transient vendor unavailability (5xx-equivalent). The kernel retries - // with backoff; a candidate for capability-aware fallback. - ModelErrorCategory_MODEL_ERROR_CATEGORY_OVERLOADED ModelErrorCategory = 3 - // Bad, expired, or missing credentials. The kernel MUST NOT retry or - // silently fall back; this surfaces to a human. - ModelErrorCategory_MODEL_ERROR_CATEGORY_AUTH_ERROR ModelErrorCategory = 4 - // A malformed request — almost always a kernel/adapter bug. The kernel - // MUST NOT retry as-is. - ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST ModelErrorCategory = 5 - // The vendor refused or filtered the content. Surfaced distinctly from - // a generic failure so policy/UX can handle it differently. - ModelErrorCategory_MODEL_ERROR_CATEGORY_CONTENT_FILTERED ModelErrorCategory = 6 - // Anything else. MUST include raw_detail for debugging; treated as - // non-retryable by default. - ModelErrorCategory_MODEL_ERROR_CATEGORY_UNKNOWN ModelErrorCategory = 7 -) - -// Enum value maps for ModelErrorCategory. -var ( - ModelErrorCategory_name = map[int32]string{ - 0: "MODEL_ERROR_CATEGORY_UNSPECIFIED", - 1: "MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED", - 2: "MODEL_ERROR_CATEGORY_RATE_LIMITED", - 3: "MODEL_ERROR_CATEGORY_OVERLOADED", - 4: "MODEL_ERROR_CATEGORY_AUTH_ERROR", - 5: "MODEL_ERROR_CATEGORY_INVALID_REQUEST", - 6: "MODEL_ERROR_CATEGORY_CONTENT_FILTERED", - 7: "MODEL_ERROR_CATEGORY_UNKNOWN", - } - ModelErrorCategory_value = map[string]int32{ - "MODEL_ERROR_CATEGORY_UNSPECIFIED": 0, - "MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED": 1, - "MODEL_ERROR_CATEGORY_RATE_LIMITED": 2, - "MODEL_ERROR_CATEGORY_OVERLOADED": 3, - "MODEL_ERROR_CATEGORY_AUTH_ERROR": 4, - "MODEL_ERROR_CATEGORY_INVALID_REQUEST": 5, - "MODEL_ERROR_CATEGORY_CONTENT_FILTERED": 6, - "MODEL_ERROR_CATEGORY_UNKNOWN": 7, - } -) - -func (x ModelErrorCategory) Enum() *ModelErrorCategory { - p := new(ModelErrorCategory) - *p = x - return p -} - -func (x ModelErrorCategory) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (ModelErrorCategory) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_model_v1_model_proto_enumTypes[4].Descriptor() -} - -func (ModelErrorCategory) Type() protoreflect.EnumType { - return &file_pluggableharness_model_v1_model_proto_enumTypes[4] -} - -func (x ModelErrorCategory) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use ModelErrorCategory.Descriptor instead. -func (ModelErrorCategory) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{4} -} - -// GetCapabilitiesRequest is empty: model.md §2 defines GetCapabilities -// as taking no request parameters. -type GetCapabilitiesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetCapabilitiesRequest) Reset() { - *x = GetCapabilitiesRequest{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetCapabilitiesRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetCapabilitiesRequest) ProtoMessage() {} - -func (x *GetCapabilitiesRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[0] - 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 GetCapabilitiesRequest.ProtoReflect.Descriptor instead. -func (*GetCapabilitiesRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{0} -} - -// GetCapabilitiesResponse wraps Capabilities for the RPC signature, per -// this repo's per-RPC envelope convention (.claude/rules/proto.md). -type GetCapabilitiesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Capabilities *Capabilities `protobuf:"bytes,1,opt,name=capabilities,proto3" json:"capabilities,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetCapabilitiesResponse) Reset() { - *x = GetCapabilitiesResponse{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetCapabilitiesResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetCapabilitiesResponse) ProtoMessage() {} - -func (x *GetCapabilitiesResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[1] - 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 GetCapabilitiesResponse.ProtoReflect.Descriptor instead. -func (*GetCapabilitiesResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{1} -} - -func (x *GetCapabilitiesResponse) GetCapabilities() *Capabilities { - if x != nil { - return x.Capabilities - } - return nil -} - -// Capabilities is GetCapabilities' response payload: every model this -// plugin can serve, plus provider-wide declarations that apply once, not -// per model. -type Capabilities struct { - state protoimpl.MessageState `protogen:"open.v1"` - // One ModelSpec per model the plugin can serve. MUST have at least one - // entry. - Models []*ModelSpec `protobuf:"bytes,1,rep,name=models,proto3" json:"models,omitempty"` - // Slash commands this provider contributes, declared once for the - // provider as a whole (not per model), per model.md §2 and - // configuration.md §5 / frontend.md §5. MAY be empty. - SlashCommands []*v1.SlashCommandSpec `protobuf:"bytes,2,rep,name=slash_commands,json=slashCommands,proto3" json:"slash_commands,omitempty"` - // The provider's agent.hcl config schema, returned alongside - // capabilities so the kernel knows what fields Configure expects, per - // configuration.md §4. - ConfigSchema *v11.ConfigSchema `protobuf:"bytes,3,opt,name=config_schema,json=configSchema,proto3" json:"config_schema,omitempty"` - // Which hook points (agent-loop/hook-dispatch.md) this plugin can serve - // via HookSubscriberService.DispatchHook. The kernel MUST reject an - // agent.hcl hook{} block naming a point not present here, at - // config-load time. - // - // Typed as common.v1.HookPoint, not hook.v1.HookPoint: hook.proto - // imports model.proto (for ModelRef/Usage on its PreModelCall/ - // PostModelResponse hook payloads), so model.proto importing hook.proto - // for this field would be a cyclic file dependency — confirmed via - // `buf build`, which rejects it outright ("detected cyclic import"). - // HookPoint itself lives in common.v1 for exactly this reason (see - // common.proto), already imported here for CallContext/Describe. - SupportedHookPoints []v12.HookPoint `protobuf:"varint,4,rep,packed,name=supported_hook_points,json=supportedHookPoints,proto3,enum=pluggableharness.common.v1.HookPoint" json:"supported_hook_points,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Capabilities) Reset() { - *x = Capabilities{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Capabilities) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Capabilities) ProtoMessage() {} - -func (x *Capabilities) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[2] - 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 Capabilities.ProtoReflect.Descriptor instead. -func (*Capabilities) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{2} -} - -func (x *Capabilities) GetModels() []*ModelSpec { - if x != nil { - return x.Models - } - return nil -} - -func (x *Capabilities) GetSlashCommands() []*v1.SlashCommandSpec { - if x != nil { - return x.SlashCommands - } - return nil -} - -func (x *Capabilities) GetConfigSchema() *v11.ConfigSchema { - if x != nil { - return x.ConfigSchema - } - return nil -} - -func (x *Capabilities) GetSupportedHookPoints() []v12.HookPoint { - if x != nil { - return x.SupportedHookPoints - } - return nil -} - -// ConfigureRequest wraps the provider's agent.hcl config block, already -// decoded from HCL/cty into a Struct by the kernel's schema-to-cty bridge, -// per model.md §3. -type ConfigureRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The decoded config value. Field contents are provider-specific (API - // key, base URL override, org/project IDs, etc.) — model.md §3 - // doesn't mandate a shape beyond what ConfigSchema (Capabilities. - // config_schema) declares. A Struct because the shape is genuinely - // provider-defined, not fixed at the proto level (see - // .claude/rules/proto.md's Struct carve-out). - Config *structpb.Struct `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ConfigureRequest) Reset() { - *x = ConfigureRequest{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ConfigureRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConfigureRequest) ProtoMessage() {} - -func (x *ConfigureRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[3] - 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 ConfigureRequest.ProtoReflect.Descriptor instead. -func (*ConfigureRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{3} -} - -func (x *ConfigureRequest) GetConfig() *structpb.Struct { - if x != nil { - return x.Config - } - return nil -} - -// ConfigureResponse is empty on success. A Configure failure (e.g. a -// missing required field) surfaces as a gRPC status carrying a -// ModelError in its structured detail, per .claude/rules/grpc.md's -// error-taxonomy convention — there is no in-band error field on this -// message. -type ConfigureResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ConfigureResponse) Reset() { - *x = ConfigureResponse{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ConfigureResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConfigureResponse) ProtoMessage() {} - -func (x *ConfigureResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[4] - 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 ConfigureResponse.ProtoReflect.Descriptor instead. -func (*ConfigureResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{4} -} - -// DescribeRequest is empty: Describe takes no request parameters, per -// configuration/lock-file.md's dev_overrides note. -type DescribeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DescribeRequest) Reset() { - *x = DescribeRequest{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DescribeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DescribeRequest) ProtoMessage() {} - -func (x *DescribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_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 DescribeRequest.ProtoReflect.Descriptor instead. -func (*DescribeRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{5} -} - -// DescribeResponse reports this plugin build's own identity, per -// configuration/lock-file.md's dev_overrides note. -type DescribeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // This plugin build's identity, as it would otherwise appear in a - // lock-file provider "" { ... } entry. - Producer *v12.ProducerRef `protobuf:"bytes,1,opt,name=producer,proto3" json:"producer,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DescribeResponse) Reset() { - *x = DescribeResponse{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DescribeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DescribeResponse) ProtoMessage() {} - -func (x *DescribeResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_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 DescribeResponse.ProtoReflect.Descriptor instead. -func (*DescribeResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{6} -} - -func (x *DescribeResponse) GetProducer() *v12.ProducerRef { - if x != nil { - return x.Producer - } - return nil -} - -// ModelSpec describes one model this provider can serve, per -// model.md §2. Every field below is MUST unless its comment says -// otherwise. -type ModelSpec struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The vendor's exact model identifier, used to select this model in - // StreamCompletionRequest.model_id. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - // The model's input token budget. - ContextWindow int64 `protobuf:"varint,2,opt,name=context_window,json=contextWindow,proto3" json:"context_window,omitempty"` - // The model's maximum output tokens per response. - MaxOutputTokens int64 `protobuf:"varint,3,opt,name=max_output_tokens,json=maxOutputTokens,proto3" json:"max_output_tokens,omitempty"` - // Whether this model can accept tool declarations and emit tool_use - // content blocks. - SupportsToolUse bool `protobuf:"varint,4,opt,name=supports_tool_use,json=supportsToolUse,proto3" json:"supports_tool_use,omitempty"` - // Whether this model can accept image content blocks. - SupportsVision bool `protobuf:"varint,5,opt,name=supports_vision,json=supportsVision,proto3" json:"supports_vision,omitempty"` - // Whether the vendor's own backend streams responses. A UX hint only - // (e.g. "don't render a live-typing cursor" when false) — the - // StreamCompletion RPC shape is always server-streaming regardless of - // this value, per model.md §1/§4. - SupportsStreaming bool `protobuf:"varint,6,opt,name=supports_streaming,json=supportsStreaming,proto3" json:"supports_streaming,omitempty"` - // Whether this model can return multiple tool_use blocks in a single - // turn. SHOULD be set accurately; a false or absent value means the - // kernel MUST serialize tool calls for this model. - SupportsParallelToolCalls *bool `protobuf:"varint,7,opt,name=supports_parallel_tool_calls,json=supportsParallelToolCalls,proto3,oneof" json:"supports_parallel_tool_calls,omitempty"` - // This model's extended-reasoning capability. MUST be present even when - // unsupported — use { supported: false } rather than omitting the - // message, so a caller never has to distinguish "unset" from "no - // thinking mode". - Thinking *ThinkingSpec `protobuf:"bytes,8,opt,name=thinking,proto3" json:"thinking,omitempty"` - // This model's prompt-caching capability. MUST be present even when - // unsupported — use { supported: false } rather than omitting the - // message. - Caching *CachingSpec `protobuf:"bytes,9,opt,name=caching,proto3" json:"caching,omitempty"` - // This model's pricing. MUST be present even for a free model (set - // Pricing.free = true). - Pricing *Pricing `protobuf:"bytes,10,opt,name=pricing,proto3" json:"pricing,omitempty"` - // Which GenerationParams.tool_choice.mode values this model accepts. - // SHOULD declare precisely which subset a vendor supports rather than - // collapsing to a bool, mirroring ThinkingSpec/CachingSpec's sum-type - // rationale above — vendors differ in which of AUTO/ANY/NONE/SPECIFIC - // they expose. Empty means this model does not support constraining - // tool choice at all (only free-form model-decides behavior); the - // kernel MUST NOT send a GenerationParams.tool_choice with a mode - // absent from this list. - SupportedToolChoiceModes []ToolChoiceMode `protobuf:"varint,11,rep,packed,name=supported_tool_choice_modes,json=supportedToolChoiceModes,proto3,enum=pluggableharness.model.v1.ToolChoiceMode" json:"supported_tool_choice_modes,omitempty"` - // Whether this model can accept document content blocks (content.v1 - // DocumentBlock, e.g. inline PDFs). Mirrors supports_vision's rule: - // the kernel MUST reject a DocumentBlock sent to a model where this is - // false, with invalid_request, rather than silently dropping it. - SupportsDocuments bool `protobuf:"varint,12,opt,name=supports_documents,json=supportsDocuments,proto3" json:"supports_documents,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ModelSpec) Reset() { - *x = ModelSpec{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ModelSpec) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ModelSpec) ProtoMessage() {} - -func (x *ModelSpec) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[7] - 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 ModelSpec.ProtoReflect.Descriptor instead. -func (*ModelSpec) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{7} -} - -func (x *ModelSpec) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *ModelSpec) GetContextWindow() int64 { - if x != nil { - return x.ContextWindow - } - return 0 -} - -func (x *ModelSpec) GetMaxOutputTokens() int64 { - if x != nil { - return x.MaxOutputTokens - } - return 0 -} - -func (x *ModelSpec) GetSupportsToolUse() bool { - if x != nil { - return x.SupportsToolUse - } - return false -} - -func (x *ModelSpec) GetSupportsVision() bool { - if x != nil { - return x.SupportsVision - } - return false -} - -func (x *ModelSpec) GetSupportsStreaming() bool { - if x != nil { - return x.SupportsStreaming - } - return false -} - -func (x *ModelSpec) GetSupportsParallelToolCalls() bool { - if x != nil && x.SupportsParallelToolCalls != nil { - return *x.SupportsParallelToolCalls - } - return false -} - -func (x *ModelSpec) GetThinking() *ThinkingSpec { - if x != nil { - return x.Thinking - } - return nil -} - -func (x *ModelSpec) GetCaching() *CachingSpec { - if x != nil { - return x.Caching - } - return nil -} - -func (x *ModelSpec) GetPricing() *Pricing { - if x != nil { - return x.Pricing - } - return nil -} - -func (x *ModelSpec) GetSupportedToolChoiceModes() []ToolChoiceMode { - if x != nil { - return x.SupportedToolChoiceModes - } - return nil -} - -func (x *ModelSpec) GetSupportsDocuments() bool { - if x != nil { - return x.SupportsDocuments - } - return false -} - -// ThinkingBudgetRange bounds the token budget a caller may request when -// ThinkingMode is THINKING_MODE_CONTINUOUS_BUDGET. -type ThinkingBudgetRange struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The smallest thinking-token budget this model accepts. - Min int64 `protobuf:"varint,1,opt,name=min,proto3" json:"min,omitempty"` - // The largest thinking-token budget this model accepts. - Max int64 `protobuf:"varint,2,opt,name=max,proto3" json:"max,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ThinkingBudgetRange) Reset() { - *x = ThinkingBudgetRange{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ThinkingBudgetRange) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ThinkingBudgetRange) ProtoMessage() {} - -func (x *ThinkingBudgetRange) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[8] - 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 ThinkingBudgetRange.ProtoReflect.Descriptor instead. -func (*ThinkingBudgetRange) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{8} -} - -func (x *ThinkingBudgetRange) GetMin() int64 { - if x != nil { - return x.Min - } - return 0 -} - -func (x *ThinkingBudgetRange) GetMax() int64 { - if x != nil { - return x.Max - } - return 0 -} - -// ThinkingSpec describes one model's extended-reasoning capability, per -// model.md §2. -type ThinkingSpec struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Whether this model has any extended-reasoning capability at all. - Supported bool `protobuf:"varint,1,opt,name=supported,proto3" json:"supported,omitempty"` - // Which reasoning-control shape this model uses. MUST be - // THINKING_MODE_NONE when supported == false. - Mode ThinkingMode `protobuf:"varint,2,opt,name=mode,proto3,enum=pluggableharness.model.v1.ThinkingMode" json:"mode,omitempty"` - // The selectable effort levels, e.g. ["low","medium","high","xhigh", - // "max"]. MUST be non-empty when mode == THINKING_MODE_DISCRETE_EFFORT; - // meaningless otherwise. - EffortLevels []string `protobuf:"bytes,3,rep,name=effort_levels,json=effortLevels,proto3" json:"effort_levels,omitempty"` - // The selectable token-budget range. MUST be present when mode == - // THINKING_MODE_CONTINUOUS_BUDGET; meaningless otherwise. - BudgetRange *ThinkingBudgetRange `protobuf:"bytes,4,opt,name=budget_range,json=budgetRange,proto3,oneof" json:"budget_range,omitempty"` - // Whether reasoning can be turned off once enabled. MUST be set - // accurately — some vendors' reasoning cannot be disabled (e.g. a - // researched Grok model defaults reasoning on with no off switch). - CanDisable bool `protobuf:"varint,5,opt,name=can_disable,json=canDisable,proto3" json:"can_disable,omitempty"` - // The effort level (discrete_effort) or budget-token value - // (continuous_budget), as a string, the vendor applies when a request - // omits thinking config entirely. MUST be set when mode != - // THINKING_MODE_NONE — makes the vendor's actual default behavior - // visible/auditable via GetCapabilities rather than hidden in adapter - // code, so a kernel wanting deterministic behavior can always send an - // explicit override. - Default *string `protobuf:"bytes,6,opt,name=default,proto3,oneof" json:"default,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ThinkingSpec) Reset() { - *x = ThinkingSpec{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ThinkingSpec) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ThinkingSpec) ProtoMessage() {} - -func (x *ThinkingSpec) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[9] - 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 ThinkingSpec.ProtoReflect.Descriptor instead. -func (*ThinkingSpec) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{9} -} - -func (x *ThinkingSpec) GetSupported() bool { - if x != nil { - return x.Supported - } - return false -} - -func (x *ThinkingSpec) GetMode() ThinkingMode { - if x != nil { - return x.Mode - } - return ThinkingMode_THINKING_MODE_UNSPECIFIED -} - -func (x *ThinkingSpec) GetEffortLevels() []string { - if x != nil { - return x.EffortLevels - } - return nil -} - -func (x *ThinkingSpec) GetBudgetRange() *ThinkingBudgetRange { - if x != nil { - return x.BudgetRange - } - return nil -} - -func (x *ThinkingSpec) GetCanDisable() bool { - if x != nil { - return x.CanDisable - } - return false -} - -func (x *ThinkingSpec) GetDefault() string { - if x != nil && x.Default != nil { - return *x.Default - } - return "" -} - -// CachingSpec describes one model's prompt-caching capability, per -// model.md §2. -type CachingSpec struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Whether this model has any prompt-caching capability at all. - Supported bool `protobuf:"varint,1,opt,name=supported,proto3" json:"supported,omitempty"` - // Which caching mechanic this model uses. MUST be CACHING_MODE_NONE - // when supported == false. - Mode CachingMode `protobuf:"varint,2,opt,name=mode,proto3,enum=pluggableharness.model.v1.CachingMode" json:"mode,omitempty"` - // Whether this provider runs its own cache-keepalive loop (e.g. a - // background goroutine re-pinging before a cache TTL expires, so a long - // tool-execution gap doesn't let the cache go cold). MUST be set, - // default false. Cache TTL mechanics are vendor-specific, so per - // operator decision this is a provider-owned behavior the kernel never - // drives — this field only tells the kernel/operator whether a given - // provider implements the optimization (model.md §2). - KeepaliveSupported bool `protobuf:"varint,3,opt,name=keepalive_supported,json=keepaliveSupported,proto3" json:"keepalive_supported,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CachingSpec) Reset() { - *x = CachingSpec{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CachingSpec) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CachingSpec) ProtoMessage() {} - -func (x *CachingSpec) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[10] - 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 CachingSpec.ProtoReflect.Descriptor instead. -func (*CachingSpec) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{10} -} - -func (x *CachingSpec) GetSupported() bool { - if x != nil { - return x.Supported - } - return false -} - -func (x *CachingSpec) GetMode() CachingMode { - if x != nil { - return x.Mode - } - return CachingMode_CACHING_MODE_UNSPECIFIED -} - -func (x *CachingSpec) GetKeepaliveSupported() bool { - if x != nil { - return x.KeepaliveSupported - } - return false -} - -// PricingTier is one time-bounded, input-size-bounded rate within a -// model's Pricing, per model.md §2. Exactly one tier MUST match at any -// given (timestamp, input_token_count) pair — timestamp resolved against -// effective_from/effective_until (an omitted bound unbounded on that -// side) AND input_token_count resolved against input_tokens_from/ -// input_tokens_until (likewise unbounded when omitted) simultaneously; -// the kernel MUST reject a Pricing value at capability-load time if its -// tiers overlap or leave a gap across either dimension, exactly as it -// already does for the time dimension alone. -type PricingTier struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The moment this tier becomes active. Omitted means "since this plugin - // version was published". Refines model.md §2's "ISO 8601 - // date/timestamp" into the native well-known type. - EffectiveFrom *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=effective_from,json=effectiveFrom,proto3,oneof" json:"effective_from,omitempty"` - // The moment this tier stops being active. Omitted means "still - // current" — an omitted effective_until marks the currently active - // tier. Refines model.md §2's "ISO 8601" into the native well-known - // type. - EffectiveUntil *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=effective_until,json=effectiveUntil,proto3,oneof" json:"effective_until,omitempty"` - // Cost per million input tokens, realtime rate. - InputPerMtok float64 `protobuf:"fixed64,3,opt,name=input_per_mtok,json=inputPerMtok,proto3" json:"input_per_mtok,omitempty"` - // Cost per million output tokens, realtime rate. - OutputPerMtok float64 `protobuf:"fixed64,4,opt,name=output_per_mtok,json=outputPerMtok,proto3" json:"output_per_mtok,omitempty"` - // Cost per million cache-write tokens. MUST be present iff - // CachingSpec.supported. - CacheWritePerMtok *float64 `protobuf:"fixed64,5,opt,name=cache_write_per_mtok,json=cacheWritePerMtok,proto3,oneof" json:"cache_write_per_mtok,omitempty"` - // Cost per million cache-read tokens, typically far cheaper than - // input_per_mtok — the entire point of caching. MUST be present iff - // CachingSpec.supported. - CacheReadPerMtok *float64 `protobuf:"fixed64,6,opt,name=cache_read_per_mtok,json=cacheReadPerMtok,proto3,oneof" json:"cache_read_per_mtok,omitempty"` - // A vendor's discounted batch/async input rate, where one exists (e.g. - // a researched Gemini batch tier). MAY be present. - BatchInputPerMtok *float64 `protobuf:"fixed64,7,opt,name=batch_input_per_mtok,json=batchInputPerMtok,proto3,oneof" json:"batch_input_per_mtok,omitempty"` - // A vendor's discounted batch/async output rate, paired with - // batch_input_per_mtok. MAY be present. - BatchOutputPerMtok *float64 `protobuf:"fixed64,8,opt,name=batch_output_per_mtok,json=batchOutputPerMtok,proto3,oneof" json:"batch_output_per_mtok,omitempty"` - // The smallest accumulated-input-token count this tier applies to, - // inclusive. Omitted means unbounded below (matches any input size down - // to zero). Real vendors price by input size as well as by time — e.g. - // a distinct, higher rate once a request's input exceeds 200k tokens — - // and this field lets a tier declare that dimension alongside the - // existing effective_from/effective_until time bounds. Refines - // model/data-types.md#pricing's tier-matching rule into two dimensions. - InputTokensFrom *int64 `protobuf:"varint,9,opt,name=input_tokens_from,json=inputTokensFrom,proto3,oneof" json:"input_tokens_from,omitempty"` - // The input-token count this tier stops applying to, exclusive. - // Omitted means unbounded above. Half-open with input_tokens_from, - // matching effective_from/effective_until's half-open convention. - InputTokensUntil *int64 `protobuf:"varint,10,opt,name=input_tokens_until,json=inputTokensUntil,proto3,oneof" json:"input_tokens_until,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PricingTier) Reset() { - *x = PricingTier{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PricingTier) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PricingTier) ProtoMessage() {} - -func (x *PricingTier) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[11] - 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 PricingTier.ProtoReflect.Descriptor instead. -func (*PricingTier) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{11} -} - -func (x *PricingTier) GetEffectiveFrom() *timestamppb.Timestamp { - if x != nil { - return x.EffectiveFrom - } - return nil -} - -func (x *PricingTier) GetEffectiveUntil() *timestamppb.Timestamp { - if x != nil { - return x.EffectiveUntil - } - return nil -} - -func (x *PricingTier) GetInputPerMtok() float64 { - if x != nil { - return x.InputPerMtok - } - return 0 -} - -func (x *PricingTier) GetOutputPerMtok() float64 { - if x != nil { - return x.OutputPerMtok - } - return 0 -} - -func (x *PricingTier) GetCacheWritePerMtok() float64 { - if x != nil && x.CacheWritePerMtok != nil { - return *x.CacheWritePerMtok - } - return 0 -} - -func (x *PricingTier) GetCacheReadPerMtok() float64 { - if x != nil && x.CacheReadPerMtok != nil { - return *x.CacheReadPerMtok - } - return 0 -} - -func (x *PricingTier) GetBatchInputPerMtok() float64 { - if x != nil && x.BatchInputPerMtok != nil { - return *x.BatchInputPerMtok - } - return 0 -} - -func (x *PricingTier) GetBatchOutputPerMtok() float64 { - if x != nil && x.BatchOutputPerMtok != nil { - return *x.BatchOutputPerMtok - } - return 0 -} - -func (x *PricingTier) GetInputTokensFrom() int64 { - if x != nil && x.InputTokensFrom != nil { - return *x.InputTokensFrom - } - return 0 -} - -func (x *PricingTier) GetInputTokensUntil() int64 { - if x != nil && x.InputTokensUntil != nil { - return *x.InputTokensUntil - } - return 0 -} - -// Pricing describes one model's cost structure, per model.md §2. MUST -// be present on every ModelSpec, even a free one. -type Pricing struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The pricing currency. MUST be "USD" for v1; reserved for future - // multi-currency support, not acted on by the kernel yet. - Currency string `protobuf:"bytes,1,opt,name=currency,proto3" json:"currency,omitempty"` - // True for a local/free-to-run model (e.g. an Ollama-served model). - // When true, tiers MAY be omitted entirely. - Free bool `protobuf:"varint,2,opt,name=free,proto3" json:"free,omitempty"` - // This model's rate tiers, ordered or not — resolution is by - // effective_from/effective_until, not array position. MUST have at - // least one entry unless free == true. Exactly one tier MUST match any - // given timestamp; the kernel MUST reject overlapping or gapped tiers - // at capability-load time. - Tiers []*PricingTier `protobuf:"bytes,3,rep,name=tiers,proto3" json:"tiers,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Pricing) Reset() { - *x = Pricing{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Pricing) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Pricing) ProtoMessage() {} - -func (x *Pricing) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[12] - 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 Pricing.ProtoReflect.Descriptor instead. -func (*Pricing) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{12} -} - -func (x *Pricing) GetCurrency() string { - if x != nil { - return x.Currency - } - return "" -} - -func (x *Pricing) GetFree() bool { - if x != nil { - return x.Free - } - return false -} - -func (x *Pricing) GetTiers() []*PricingTier { - if x != nil { - return x.Tiers - } - return nil -} - -// StreamCompletionRequest is StreamCompletion's request: the full -// canonical conversation, available tools, and generation params for one -// completion, per model.md §4. -type StreamCompletionRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The canonical conversation history, in emission order (model.md - // §5). - Messages []*v13.Message `protobuf:"bytes,1,rep,name=messages,proto3" json:"messages,omitempty"` - // Selects which of this provider's ModelSpec.id to use. - ModelId string `protobuf:"bytes,2,opt,name=model_id,json=modelId,proto3" json:"model_id,omitempty"` - // The tools available to the model on this turn, described in the - // shared JSON-Schema subset (model.md §6). MAY be empty. - Tools []*ToolDeclaration `protobuf:"bytes,3,rep,name=tools,proto3" json:"tools,omitempty"` - // Generation-time overrides. Omitted means every param takes its - // model-specific default. - Params *GenerationParams `protobuf:"bytes,4,opt,name=params,proto3,oneof" json:"params,omitempty"` - // The kernel-assembled context chain — the accumulated output of every - // context provider's Contribute call plus memory recall - // (context/protocol.md#contribute-the-context-assemble-rpc), in chain - // order (the same order context.md's ContextRequest.prior_sections/ - // Contribute response chain uses: each provider appends after the - // sections it received). This is distinct from `messages` above: it is - // system-level/preamble content, never a conversational turn, which is - // why it's carried as its own field rather than folded into `messages` - // as a synthetic message — content.v1.Role deliberately has no SYSTEM - // value (content.proto's Role comment) precisely because this content - // is never a Message with a role. Each model-provider adapter maps - // this chain to its vendor's own system/preamble mechanism (a top-level - // `system` string, a leading system-role message, etc.) — how that - // mapping happens is adapter-internal and not part of this wire - // contract. - AssembledContext []*v13.ContextSection `protobuf:"bytes,5,rep,name=assembled_context,json=assembledContext,proto3" json:"assembled_context,omitempty"` - // Session/turn/working-directory attribution for this call. MUST be set - // by the kernel on every StreamCompletionRequest. This is what the - // plugin passes back on its own KernelCallbackService.Emit and Log - // calls (kernel-callbacks.md#emit) for correlation, without having to - // separately thread session_id/turn_id through adapter-internal call - // sites by hand. - CallContext *v12.CallContext `protobuf:"bytes,6,opt,name=call_context,json=callContext,proto3" json:"call_context,omitempty"` - // Cache breakpoints for this request, wire-level and request-scoped — - // NOT carried on the persisted content.v1.ContentBlock, since a - // breakpoint's placement is a per-request optimization decision, not a - // durable property of the conversation history itself. Meaningful only - // when the target model's CachingSpec.mode == - // CACHING_MODE_EXPLICIT_MARKERS; a model-provider adapter targeting a - // model whose CachingSpec.mode is CACHING_MODE_IMPLICIT_AUTOMATIC or - // CACHING_MODE_NONE MUST ignore this field rather than error on it. - // Placement is a kernel decision, not the plugin's: the kernel knows - // each assembled_context section's Stability (content.proto's Stability - // enum) and each message's position, so it places breakpoints at - // natural stable-prefix boundaries — see - // model/protocol.md#cache-breakpoint-placement-policy. - CacheBreakpoints []*CacheBreakpoint `protobuf:"bytes,7,rep,name=cache_breakpoints,json=cacheBreakpoints,proto3" json:"cache_breakpoints,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StreamCompletionRequest) Reset() { - *x = StreamCompletionRequest{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StreamCompletionRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StreamCompletionRequest) ProtoMessage() {} - -func (x *StreamCompletionRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[13] - 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 StreamCompletionRequest.ProtoReflect.Descriptor instead. -func (*StreamCompletionRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{13} -} - -func (x *StreamCompletionRequest) GetMessages() []*v13.Message { - if x != nil { - return x.Messages - } - return nil -} - -func (x *StreamCompletionRequest) GetModelId() string { - if x != nil { - return x.ModelId - } - return "" -} - -func (x *StreamCompletionRequest) GetTools() []*ToolDeclaration { - if x != nil { - return x.Tools - } - return nil -} - -func (x *StreamCompletionRequest) GetParams() *GenerationParams { - if x != nil { - return x.Params - } - return nil -} - -func (x *StreamCompletionRequest) GetAssembledContext() []*v13.ContextSection { - if x != nil { - return x.AssembledContext - } - return nil -} - -func (x *StreamCompletionRequest) GetCallContext() *v12.CallContext { - if x != nil { - return x.CallContext - } - return nil -} - -func (x *StreamCompletionRequest) GetCacheBreakpoints() []*CacheBreakpoint { - if x != nil { - return x.CacheBreakpoints - } - return nil -} - -// CacheBreakpoint marks one position in a StreamCompletionRequest where -// the kernel wants the adapter to insert a vendor-native cache-control -// marker, per StreamCompletionRequest.cache_breakpoints above. -type CacheBreakpoint struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The position this breakpoint marks. Exactly one variant is set. - // - // Types that are valid to be assigned to Position: - // - // *CacheBreakpoint_AfterAssembledContext_ - // *CacheBreakpoint_AfterTools_ - // *CacheBreakpoint_AfterMessageIndex - Position isCacheBreakpoint_Position `protobuf_oneof:"position"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CacheBreakpoint) Reset() { - *x = CacheBreakpoint{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CacheBreakpoint) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CacheBreakpoint) ProtoMessage() {} - -func (x *CacheBreakpoint) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[14] - 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 CacheBreakpoint.ProtoReflect.Descriptor instead. -func (*CacheBreakpoint) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{14} -} - -func (x *CacheBreakpoint) GetPosition() isCacheBreakpoint_Position { - if x != nil { - return x.Position - } - return nil -} - -func (x *CacheBreakpoint) GetAfterAssembledContext() *CacheBreakpoint_AfterAssembledContext { - if x != nil { - if x, ok := x.Position.(*CacheBreakpoint_AfterAssembledContext_); ok { - return x.AfterAssembledContext - } - } - return nil -} - -func (x *CacheBreakpoint) GetAfterTools() *CacheBreakpoint_AfterTools { - if x != nil { - if x, ok := x.Position.(*CacheBreakpoint_AfterTools_); ok { - return x.AfterTools - } - } - return nil -} - -func (x *CacheBreakpoint) GetAfterMessageIndex() int64 { - if x != nil { - if x, ok := x.Position.(*CacheBreakpoint_AfterMessageIndex); ok { - return x.AfterMessageIndex - } - } - return 0 -} - -type isCacheBreakpoint_Position interface { - isCacheBreakpoint_Position() -} - -type CacheBreakpoint_AfterAssembledContext_ struct { - // Immediately after the assembled_context chain — the kernel's most - // common choice, since assembled_context is usually the longest - // stable prefix (context/data-types.md#ordering--chaining orders - // STABILITY_STATIC sections before STABILITY_DYNAMIC ones). - AfterAssembledContext *CacheBreakpoint_AfterAssembledContext `protobuf:"bytes,1,opt,name=after_assembled_context,json=afterAssembledContext,proto3,oneof"` -} - -type CacheBreakpoint_AfterTools_ struct { - // Immediately after the tools declaration list. - AfterTools *CacheBreakpoint_AfterTools `protobuf:"bytes,2,opt,name=after_tools,json=afterTools,proto3,oneof"` -} - -type CacheBreakpoint_AfterMessageIndex struct { - // Immediately after the message at this zero-based index within - // `messages`. - AfterMessageIndex int64 `protobuf:"varint,3,opt,name=after_message_index,json=afterMessageIndex,proto3,oneof"` -} - -func (*CacheBreakpoint_AfterAssembledContext_) isCacheBreakpoint_Position() {} - -func (*CacheBreakpoint_AfterTools_) isCacheBreakpoint_Position() {} - -func (*CacheBreakpoint_AfterMessageIndex) isCacheBreakpoint_Position() {} - -// ToolDeclaration is one tool the model may call on this turn, per -// model.md §6. Each model-provider adapter translates this into its -// vendor's own tool-definition wire format. -type ToolDeclaration struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The tool's name, as the model must reference it in a ToolUseBlock. - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - // Human-readable description shown to the model to help it decide - // whether and how to call this tool. - Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` - // The tool's input shape, in the restricted JSON-Schema subset shared - // across categories (model.md §6, pluggableharness.schema.v1.Schema). - InputSchema *v14.Schema `protobuf:"bytes,3,opt,name=input_schema,json=inputSchema,proto3" json:"input_schema,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ToolDeclaration) Reset() { - *x = ToolDeclaration{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ToolDeclaration) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ToolDeclaration) ProtoMessage() {} - -func (x *ToolDeclaration) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_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 ToolDeclaration.ProtoReflect.Descriptor instead. -func (*ToolDeclaration) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{15} -} - -func (x *ToolDeclaration) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *ToolDeclaration) GetDescription() string { - if x != nil { - return x.Description - } - return "" -} - -func (x *ToolDeclaration) GetInputSchema() *v14.Schema { - if x != nil { - return x.InputSchema - } - return nil -} - -// GenerationParams carries per-request overrides of otherwise -// model-default generation behavior. -type GenerationParams struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Selects one of ThinkingSpec.effort_levels. Meaningful only when the - // target model's ThinkingSpec.mode == THINKING_MODE_DISCRETE_EFFORT. - ThinkingEffort *string `protobuf:"bytes,1,opt,name=thinking_effort,json=thinkingEffort,proto3,oneof" json:"thinking_effort,omitempty"` - // Selects a token budget within ThinkingSpec.budget_range. Meaningful - // only when the target model's ThinkingSpec.mode == - // THINKING_MODE_CONTINUOUS_BUDGET. - ThinkingBudgetTokens *int64 `protobuf:"varint,2,opt,name=thinking_budget_tokens,json=thinkingBudgetTokens,proto3,oneof" json:"thinking_budget_tokens,omitempty"` - // Per-request override of ModelSpec.max_output_tokens. Omitted means - // use the model's default. - MaxOutputTokens *int64 `protobuf:"varint,3,opt,name=max_output_tokens,json=maxOutputTokens,proto3,oneof" json:"max_output_tokens,omitempty"` - // Sampling temperature. Omitted means use the model's default. Range - // and exact semantics are vendor-specific; the kernel does not clamp - // or reinterpret this value, it is passed through to the adapter as - // given. - Temperature *float64 `protobuf:"fixed64,4,opt,name=temperature,proto3,oneof" json:"temperature,omitempty"` - // Sequences that, if generated, MUST cause the model to stop before - // producing them. Omitted/empty means no caller-supplied stop - // sequences. When a vendor honors one of these, the plugin MUST report - // it back via StreamEvent.Stop.matched_stop_sequence with StopReason - // STOP_REASON_STOP_SEQUENCE. - StopSequences []string `protobuf:"bytes,5,rep,name=stop_sequences,json=stopSequences,proto3" json:"stop_sequences,omitempty"` - // Constrains whether/how the model must use a tool this turn. Omitted - // means the model decides freely (equivalent to - // TOOL_CHOICE_MODE_AUTO). Meaningful only when the target model's - // ModelSpec.supported_tool_choice_modes is non-empty; the kernel MUST - // NOT send a mode absent from that list, mirroring - // thinking_effort/thinking_budget_tokens' ThinkingSpec-validation rule - // above — an unsupported mode is a kernel-level reject-or-fallback, not - // something forwarded to the vendor. - ToolChoice *ToolChoice `protobuf:"bytes,6,opt,name=tool_choice,json=toolChoice,proto3,oneof" json:"tool_choice,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GenerationParams) Reset() { - *x = GenerationParams{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GenerationParams) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GenerationParams) ProtoMessage() {} - -func (x *GenerationParams) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_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 GenerationParams.ProtoReflect.Descriptor instead. -func (*GenerationParams) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{16} -} - -func (x *GenerationParams) GetThinkingEffort() string { - if x != nil && x.ThinkingEffort != nil { - return *x.ThinkingEffort - } - return "" -} - -func (x *GenerationParams) GetThinkingBudgetTokens() int64 { - if x != nil && x.ThinkingBudgetTokens != nil { - return *x.ThinkingBudgetTokens - } - return 0 -} - -func (x *GenerationParams) GetMaxOutputTokens() int64 { - if x != nil && x.MaxOutputTokens != nil { - return *x.MaxOutputTokens - } - return 0 -} - -func (x *GenerationParams) GetTemperature() float64 { - if x != nil && x.Temperature != nil { - return *x.Temperature - } - return 0 -} - -func (x *GenerationParams) GetStopSequences() []string { - if x != nil { - return x.StopSequences - } - return nil -} - -func (x *GenerationParams) GetToolChoice() *ToolChoice { - if x != nil { - return x.ToolChoice - } - return nil -} - -// ToolChoice carries one request's tool-invocation constraint, per -// GenerationParams.tool_choice above. -type ToolChoice struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Which constraint shape applies. MUST be set. - Mode ToolChoiceMode `protobuf:"varint,1,opt,name=mode,proto3,enum=pluggableharness.model.v1.ToolChoiceMode" json:"mode,omitempty"` - // The tool the model MUST call. MUST be set, and MUST name a tool - // present in StreamCompletionRequest.tools, when mode == - // TOOL_CHOICE_MODE_SPECIFIC; meaningless and MUST be omitted for every - // other mode. - ToolName *string `protobuf:"bytes,2,opt,name=tool_name,json=toolName,proto3,oneof" json:"tool_name,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ToolChoice) Reset() { - *x = ToolChoice{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[17] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ToolChoice) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ToolChoice) ProtoMessage() {} - -func (x *ToolChoice) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[17] - 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 ToolChoice.ProtoReflect.Descriptor instead. -func (*ToolChoice) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{17} -} - -func (x *ToolChoice) GetMode() ToolChoiceMode { - if x != nil { - return x.Mode - } - return ToolChoiceMode_TOOL_CHOICE_MODE_UNSPECIFIED -} - -func (x *ToolChoice) GetToolName() string { - if x != nil && x.ToolName != nil { - return *x.ToolName - } - return "" -} - -// StreamEvent is one message in the stream StreamCompletion returns, per -// model.md §4. Exactly one variant is set. -type StreamEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Types that are valid to be assigned to Event: - // - // *StreamEvent_TextDelta_ - // *StreamEvent_ThinkingDelta_ - // *StreamEvent_ThinkingSignature_ - // *StreamEvent_ToolCallStart_ - // *StreamEvent_ToolCallDelta_ - // *StreamEvent_ToolCallDone_ - // *StreamEvent_Usage - // *StreamEvent_Stop_ - // *StreamEvent_Error_ - Event isStreamEvent_Event `protobuf_oneof:"event"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StreamEvent) Reset() { - *x = StreamEvent{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[18] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StreamEvent) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StreamEvent) ProtoMessage() {} - -func (x *StreamEvent) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[18] - 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 StreamEvent.ProtoReflect.Descriptor instead. -func (*StreamEvent) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{18} -} - -func (x *StreamEvent) GetEvent() isStreamEvent_Event { - if x != nil { - return x.Event - } - return nil -} - -func (x *StreamEvent) GetTextDelta() *StreamEvent_TextDelta { - if x != nil { - if x, ok := x.Event.(*StreamEvent_TextDelta_); ok { - return x.TextDelta - } - } - return nil -} - -func (x *StreamEvent) GetThinkingDelta() *StreamEvent_ThinkingDelta { - if x != nil { - if x, ok := x.Event.(*StreamEvent_ThinkingDelta_); ok { - return x.ThinkingDelta - } - } - return nil -} - -func (x *StreamEvent) GetThinkingSignature() *StreamEvent_ThinkingSignature { - if x != nil { - if x, ok := x.Event.(*StreamEvent_ThinkingSignature_); ok { - return x.ThinkingSignature - } - } - return nil -} - -func (x *StreamEvent) GetToolCallStart() *StreamEvent_ToolCallStart { - if x != nil { - if x, ok := x.Event.(*StreamEvent_ToolCallStart_); ok { - return x.ToolCallStart - } - } - return nil -} - -func (x *StreamEvent) GetToolCallDelta() *StreamEvent_ToolCallDelta { - if x != nil { - if x, ok := x.Event.(*StreamEvent_ToolCallDelta_); ok { - return x.ToolCallDelta - } - } - return nil -} - -func (x *StreamEvent) GetToolCallDone() *StreamEvent_ToolCallDone { - if x != nil { - if x, ok := x.Event.(*StreamEvent_ToolCallDone_); ok { - return x.ToolCallDone - } - } - return nil -} - -func (x *StreamEvent) GetUsage() *Usage { - if x != nil { - if x, ok := x.Event.(*StreamEvent_Usage); ok { - return x.Usage - } - } - return nil -} - -func (x *StreamEvent) GetStop() *StreamEvent_Stop { - if x != nil { - if x, ok := x.Event.(*StreamEvent_Stop_); ok { - return x.Stop - } - } - return nil -} - -func (x *StreamEvent) GetError() *StreamEvent_Error { - if x != nil { - if x, ok := x.Event.(*StreamEvent_Error_); ok { - return x.Error - } - } - return nil -} - -type isStreamEvent_Event interface { - isStreamEvent_Event() -} - -type StreamEvent_TextDelta_ struct { - // An incremental fragment of assistant text output. - TextDelta *StreamEvent_TextDelta `protobuf:"bytes,1,opt,name=text_delta,json=textDelta,proto3,oneof"` -} - -type StreamEvent_ThinkingDelta_ struct { - // An incremental fragment of the model's reasoning output. - ThinkingDelta *StreamEvent_ThinkingDelta `protobuf:"bytes,2,opt,name=thinking_delta,json=thinkingDelta,proto3,oneof"` -} - -type StreamEvent_ThinkingSignature_ struct { - // The vendor's opaque integrity token for the reasoning just emitted. - ThinkingSignature *StreamEvent_ThinkingSignature `protobuf:"bytes,3,opt,name=thinking_signature,json=thinkingSignature,proto3,oneof"` -} - -type StreamEvent_ToolCallStart_ struct { - // The model has begun requesting a tool invocation. - ToolCallStart *StreamEvent_ToolCallStart `protobuf:"bytes,4,opt,name=tool_call_start,json=toolCallStart,proto3,oneof"` -} - -type StreamEvent_ToolCallDelta_ struct { - // An incremental fragment of a tool call's arguments. - ToolCallDelta *StreamEvent_ToolCallDelta `protobuf:"bytes,5,opt,name=tool_call_delta,json=toolCallDelta,proto3,oneof"` -} - -type StreamEvent_ToolCallDone_ struct { - // A tool call's arguments are complete. - ToolCallDone *StreamEvent_ToolCallDone `protobuf:"bytes,6,opt,name=tool_call_done,json=toolCallDone,proto3,oneof"` -} - -type StreamEvent_Usage struct { - // Token accounting for this completion. - Usage *Usage `protobuf:"bytes,7,opt,name=usage,proto3,oneof"` -} - -type StreamEvent_Stop_ struct { - // The completion has ended. - Stop *StreamEvent_Stop `protobuf:"bytes,8,opt,name=stop,proto3,oneof"` -} - -type StreamEvent_Error_ struct { - // The completion failed. - Error *StreamEvent_Error `protobuf:"bytes,9,opt,name=error,proto3,oneof"` -} - -func (*StreamEvent_TextDelta_) isStreamEvent_Event() {} - -func (*StreamEvent_ThinkingDelta_) isStreamEvent_Event() {} - -func (*StreamEvent_ThinkingSignature_) isStreamEvent_Event() {} - -func (*StreamEvent_ToolCallStart_) isStreamEvent_Event() {} - -func (*StreamEvent_ToolCallDelta_) isStreamEvent_Event() {} - -func (*StreamEvent_ToolCallDone_) isStreamEvent_Event() {} - -func (*StreamEvent_Usage) isStreamEvent_Event() {} - -func (*StreamEvent_Stop_) isStreamEvent_Event() {} - -func (*StreamEvent_Error_) isStreamEvent_Event() {} - -// Usage carries token accounting for one completion, per model.md §4.1. -// The kernel computes and persists cost_usd from these counts plus the -// matching PricingTier — the plugin never computes cost itself. Promoted -// to a top-level message, rather than nested under StreamEvent, because -// it is reused outside the stream (event payloads, frontend usage -// updates — forthcoming). -type Usage struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Input tokens consumed by this completion. - InputTokens int64 `protobuf:"varint,1,opt,name=input_tokens,json=inputTokens,proto3" json:"input_tokens,omitempty"` - // Output tokens produced by this completion. - OutputTokens int64 `protobuf:"varint,2,opt,name=output_tokens,json=outputTokens,proto3" json:"output_tokens,omitempty"` - // Tokens read from cache, if the model supports caching. Never also - // counted in input_tokens. - CacheReadTokens *int64 `protobuf:"varint,3,opt,name=cache_read_tokens,json=cacheReadTokens,proto3,oneof" json:"cache_read_tokens,omitempty"` - // Tokens written to cache, if the model supports caching. Never also - // counted in input_tokens. - CacheWriteTokens *int64 `protobuf:"varint,4,opt,name=cache_write_tokens,json=cacheWriteTokens,proto3,oneof" json:"cache_write_tokens,omitempty"` - // Thinking/reasoning tokens, when the vendor reports them as a - // distinct count (ThinkingSpec.supported models only). Never also - // counted in output_tokens — a vendor that folds reasoning tokens into - // its reported output_tokens has no separate figure to report here, so - // this stays unset in that case rather than being derived/subtracted. - // Billed at the output rate (PricingTier.output_per_mtok) unless a - // future Pricing revision declares a distinct reasoning rate — there is - // none as of this revision. - ReasoningTokens *int64 `protobuf:"varint,5,opt,name=reasoning_tokens,json=reasoningTokens,proto3,oneof" json:"reasoning_tokens,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Usage) Reset() { - *x = Usage{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[19] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Usage) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Usage) ProtoMessage() {} - -func (x *Usage) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[19] - 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 Usage.ProtoReflect.Descriptor instead. -func (*Usage) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{19} -} - -func (x *Usage) GetInputTokens() int64 { - if x != nil { - return x.InputTokens - } - return 0 -} - -func (x *Usage) GetOutputTokens() int64 { - if x != nil { - return x.OutputTokens - } - return 0 -} - -func (x *Usage) GetCacheReadTokens() int64 { - if x != nil && x.CacheReadTokens != nil { - return *x.CacheReadTokens - } - return 0 -} - -func (x *Usage) GetCacheWriteTokens() int64 { - if x != nil && x.CacheWriteTokens != nil { - return *x.CacheWriteTokens - } - return 0 -} - -func (x *Usage) GetReasoningTokens() int64 { - if x != nil && x.ReasoningTokens != nil { - return *x.ReasoningTokens - } - return 0 -} - -// CountTokensRequest is CountTokens' request: the raw text to count, per -// model.md §2.1. -type CountTokensRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The text to count tokens for. - Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` - // Selects which of this provider's ModelSpec.id to count against — a - // provider serving several models MAY have distinct tokenizers per - // model. MUST be set. - ModelId string `protobuf:"bytes,2,opt,name=model_id,json=modelId,proto3" json:"model_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CountTokensRequest) Reset() { - *x = CountTokensRequest{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[20] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CountTokensRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CountTokensRequest) ProtoMessage() {} - -func (x *CountTokensRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[20] - 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 CountTokensRequest.ProtoReflect.Descriptor instead. -func (*CountTokensRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{20} -} - -func (x *CountTokensRequest) GetText() string { - if x != nil { - return x.Text - } - return "" -} - -func (x *CountTokensRequest) GetModelId() string { - if x != nil { - return x.ModelId - } - return "" -} - -// CountTokensResponse is CountTokens' response. -type CountTokensResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The exact token count, per this model's real vendor tokenizer. - Count int64 `protobuf:"varint,1,opt,name=count,proto3" json:"count,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CountTokensResponse) Reset() { - *x = CountTokensResponse{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[21] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CountTokensResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CountTokensResponse) ProtoMessage() {} - -func (x *CountTokensResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[21] - 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 CountTokensResponse.ProtoReflect.Descriptor instead. -func (*CountTokensResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{21} -} - -func (x *CountTokensResponse) GetCount() int64 { - if x != nil { - return x.Count - } - return 0 -} - -// RenderRequest carries the opaque payload to render, per model.md §7. -type RenderRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The opaque emitted payload to render — the Emit->Render->Paint - // pipeline's deliberate carve-out from the strong-typing rule (see - // .claude/rules/grpc.md), never interpreted by the kernel. - Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` - // The schema_version `payload` was emitted under, per - // ../frontend/render-tree.md#schema-versioning. MUST be set — lets this - // Render implementation interpret a payload emitted by an older plugin - // version consistently across a replayed session, the same - // "supersedes" reasoning architecture.md applies elsewhere to - // schema-drift-sensitive persisted data. - SchemaVersion string `protobuf:"bytes,2,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RenderRequest) Reset() { - *x = RenderRequest{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[22] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RenderRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RenderRequest) ProtoMessage() {} - -func (x *RenderRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[22] - 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 RenderRequest.ProtoReflect.Descriptor instead. -func (*RenderRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{22} -} - -func (x *RenderRequest) GetPayload() []byte { - if x != nil { - return x.Payload - } - return nil -} - -func (x *RenderRequest) GetSchemaVersion() string { - if x != nil { - return x.SchemaVersion - } - return "" -} - -// RenderResponse wraps the resulting RenderTree, per model.md §7. -type RenderResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The rendered tree, formally defined in frontend.md §1 and shared - // verbatim across every category's Render RPC (tool.md §7, context.md - // §9, memory.md §10) — one RenderTree type for the whole - // Emit->Render->Paint pipeline, not a per-category variant. - Tree *v15.RenderTree `protobuf:"bytes,1,opt,name=tree,proto3" json:"tree,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RenderResponse) Reset() { - *x = RenderResponse{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[23] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RenderResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RenderResponse) ProtoMessage() {} - -func (x *RenderResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[23] - 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 RenderResponse.ProtoReflect.Descriptor instead. -func (*RenderResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{23} -} - -func (x *RenderResponse) GetTree() *v15.RenderTree { - if x != nil { - return x.Tree - } - return nil -} - -// ModelError is the structured error every failure crossing this -// plugin boundary carries, per model.md §8. -type ModelError struct { - state protoimpl.MessageState `protogen:"open.v1"` - // This failure's category. MUST be set. - Category ModelErrorCategory `protobuf:"varint,1,opt,name=category,proto3,enum=pluggableharness.model.v1.ModelErrorCategory" json:"category,omitempty"` - // Human-readable description of the failure. - Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` - // Whether the kernel may retry this request as-is. - Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"` - // How long the kernel should wait before retrying, when the vendor - // supplies one (typically alongside MODEL_ERROR_CATEGORY_RATE_LIMITED). - // SHOULD be set when available. Refines model.md §8's - // "retry_after_seconds" into the native well-known type. - RetryAfter *durationpb.Duration `protobuf:"bytes,4,opt,name=retry_after,json=retryAfter,proto3,oneof" json:"retry_after,omitempty"` - // The raw vendor-provided error code or body, for debugging. SHOULD be - // set. - RawDetail *string `protobuf:"bytes,5,opt,name=raw_detail,json=rawDetail,proto3,oneof" json:"raw_detail,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ModelError) Reset() { - *x = ModelError{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[24] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ModelError) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ModelError) ProtoMessage() {} - -func (x *ModelError) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[24] - 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 ModelError.ProtoReflect.Descriptor instead. -func (*ModelError) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{24} -} - -func (x *ModelError) GetCategory() ModelErrorCategory { - if x != nil { - return x.Category - } - return ModelErrorCategory_MODEL_ERROR_CATEGORY_UNSPECIFIED -} - -func (x *ModelError) GetMessage() string { - if x != nil { - return x.Message - } - return "" -} - -func (x *ModelError) GetRetryable() bool { - if x != nil { - return x.Retryable - } - return false -} - -func (x *ModelError) GetRetryAfter() *durationpb.Duration { - if x != nil { - return x.RetryAfter - } - return nil -} - -func (x *ModelError) GetRawDetail() string { - if x != nil && x.RawDetail != nil { - return *x.RawDetail - } - return "" -} - -// ModelTarget describes the model a context or memory contribution is -// being assembled for, derived from that model's ModelSpec -// (model.md §2). Carried on context.md's ContextRequest and memory.md's -// RecallRequest so a provider can tailor its contribution (and compute -// tokens against the right budget) for the model that will actually -// consume it. -type ModelTarget struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The target model's ModelSpec.id (model.md §2) — the vendor's exact - // model identifier. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - // The target model's total input token budget (model.md §2 - // ModelSpec.context_window). - ContextWindow int64 `protobuf:"varint,2,opt,name=context_window,json=contextWindow,proto3" json:"context_window,omitempty"` - // The usable portion of context_window after the kernel reserves space - // for expected output, tool schemas, and other fixed overhead for this - // turn — the number a context/memory provider should actually budget - // its contribution against, rather than the raw context_window. - EffectiveCeiling int64 `protobuf:"varint,3,opt,name=effective_ceiling,json=effectiveCeiling,proto3" json:"effective_ceiling,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ModelTarget) Reset() { - *x = ModelTarget{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[25] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ModelTarget) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ModelTarget) ProtoMessage() {} - -func (x *ModelTarget) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[25] - 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 ModelTarget.ProtoReflect.Descriptor instead. -func (*ModelTarget) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{25} -} - -func (x *ModelTarget) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *ModelTarget) GetContextWindow() int64 { - if x != nil { - return x.ContextWindow - } - return 0 -} - -func (x *ModelTarget) GetEffectiveCeiling() int64 { - if x != nil { - return x.EffectiveCeiling - } - return 0 -} - -// ModelRef narrowly selects a model for token-counting purposes -// (kernel-callbacks.md §2 CountTokensRequest.model_ref). Optional on the -// request it's embedded in — omitted means "use the kernel's fallback -// heuristic," never "count against an unspecified model." -type ModelRef struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The declared name of the model provider plugin (common.v1.ProducerRef - // or ProviderRef's name field), e.g. "anthropic". - Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` - // The model's ModelSpec.id within that provider. - Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ModelRef) Reset() { - *x = ModelRef{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[26] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ModelRef) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ModelRef) ProtoMessage() {} - -func (x *ModelRef) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[26] - 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 ModelRef.ProtoReflect.Descriptor instead. -func (*ModelRef) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{26} -} - -func (x *ModelRef) GetProvider() string { - if x != nil { - return x.Provider - } - return "" -} - -func (x *ModelRef) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -// AfterAssembledContext is an empty marker message: its presence as the -// set oneof variant is the entire signal, no further data needed. -type CacheBreakpoint_AfterAssembledContext struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CacheBreakpoint_AfterAssembledContext) Reset() { - *x = CacheBreakpoint_AfterAssembledContext{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[27] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CacheBreakpoint_AfterAssembledContext) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CacheBreakpoint_AfterAssembledContext) ProtoMessage() {} - -func (x *CacheBreakpoint_AfterAssembledContext) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[27] - 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 CacheBreakpoint_AfterAssembledContext.ProtoReflect.Descriptor instead. -func (*CacheBreakpoint_AfterAssembledContext) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{14, 0} -} - -// AfterTools is an empty marker message: its presence as the set oneof -// variant is the entire signal, no further data needed. -type CacheBreakpoint_AfterTools struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CacheBreakpoint_AfterTools) Reset() { - *x = CacheBreakpoint_AfterTools{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[28] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CacheBreakpoint_AfterTools) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CacheBreakpoint_AfterTools) ProtoMessage() {} - -func (x *CacheBreakpoint_AfterTools) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[28] - 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 CacheBreakpoint_AfterTools.ProtoReflect.Descriptor instead. -func (*CacheBreakpoint_AfterTools) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{14, 1} -} - -// TextDelta carries one incremental fragment of assistant text output. -// MUST be supported by every plugin, both directions (model.md §5). -type StreamEvent_TextDelta struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The text fragment. - Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StreamEvent_TextDelta) Reset() { - *x = StreamEvent_TextDelta{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[29] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StreamEvent_TextDelta) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StreamEvent_TextDelta) ProtoMessage() {} - -func (x *StreamEvent_TextDelta) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[29] - 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 StreamEvent_TextDelta.ProtoReflect.Descriptor instead. -func (*StreamEvent_TextDelta) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{18, 0} -} - -func (x *StreamEvent_TextDelta) GetText() string { - if x != nil { - return x.Text - } - return "" -} - -// ThinkingDelta carries one incremental fragment of the model's -// reasoning output. Only emitted when the target model's -// ThinkingSpec.supported is true. -type StreamEvent_ThinkingDelta struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The reasoning-text fragment. - Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StreamEvent_ThinkingDelta) Reset() { - *x = StreamEvent_ThinkingDelta{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[30] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StreamEvent_ThinkingDelta) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StreamEvent_ThinkingDelta) ProtoMessage() {} - -func (x *StreamEvent_ThinkingDelta) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[30] - 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 StreamEvent_ThinkingDelta.ProtoReflect.Descriptor instead. -func (*StreamEvent_ThinkingDelta) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{18, 1} -} - -func (x *StreamEvent_ThinkingDelta) GetText() string { - if x != nil { - return x.Text - } - return "" -} - -// ThinkingSignature carries the vendor's opaque integrity token for the -// reasoning block just completed. MUST be emitted if the vendor's -// thinking blocks carry an integrity signature (model.md §4/§5); the -// kernel MUST store and round-trip this verbatim, never inspecting or -// reformatting it, into ContentBlock's ThinkingBlock.signature. -type StreamEvent_ThinkingSignature struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The opaque, vendor-specific signature bytes. - Signature []byte `protobuf:"bytes,1,opt,name=signature,proto3" json:"signature,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StreamEvent_ThinkingSignature) Reset() { - *x = StreamEvent_ThinkingSignature{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[31] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StreamEvent_ThinkingSignature) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StreamEvent_ThinkingSignature) ProtoMessage() {} - -func (x *StreamEvent_ThinkingSignature) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[31] - 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 StreamEvent_ThinkingSignature.ProtoReflect.Descriptor instead. -func (*StreamEvent_ThinkingSignature) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{18, 2} -} - -func (x *StreamEvent_ThinkingSignature) GetSignature() []byte { - if x != nil { - return x.Signature - } - return nil -} - -// ToolCallStart announces the model has begun requesting a tool -// invocation. -type StreamEvent_ToolCallStart struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Correlation id for the matching ToolCallDelta/ToolCallDone events - // and the resulting ToolUseBlock.id. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - // The tool's declared name (ToolDeclaration.name). - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StreamEvent_ToolCallStart) Reset() { - *x = StreamEvent_ToolCallStart{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[32] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StreamEvent_ToolCallStart) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StreamEvent_ToolCallStart) ProtoMessage() {} - -func (x *StreamEvent_ToolCallStart) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[32] - 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 StreamEvent_ToolCallStart.ProtoReflect.Descriptor instead. -func (*StreamEvent_ToolCallStart) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{18, 3} -} - -func (x *StreamEvent_ToolCallStart) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *StreamEvent_ToolCallStart) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -// ToolCallDelta carries one incremental fragment of a tool call's -// arguments, accumulated by the kernel across deltas into the final -// parsed JSON. -type StreamEvent_ToolCallDelta struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The id from the matching ToolCallStart. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - // A partial-JSON fragment of the call's arguments. - ArgumentsFragment string `protobuf:"bytes,2,opt,name=arguments_fragment,json=argumentsFragment,proto3" json:"arguments_fragment,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StreamEvent_ToolCallDelta) Reset() { - *x = StreamEvent_ToolCallDelta{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[33] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StreamEvent_ToolCallDelta) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StreamEvent_ToolCallDelta) ProtoMessage() {} - -func (x *StreamEvent_ToolCallDelta) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[33] - 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 StreamEvent_ToolCallDelta.ProtoReflect.Descriptor instead. -func (*StreamEvent_ToolCallDelta) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{18, 4} -} - -func (x *StreamEvent_ToolCallDelta) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *StreamEvent_ToolCallDelta) GetArgumentsFragment() string { - if x != nil { - return x.ArgumentsFragment - } - return "" -} - -// ToolCallDone signals a tool call's arguments are complete and ready -// for the kernel to parse and dispatch. -type StreamEvent_ToolCallDone struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The id from the matching ToolCallStart. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StreamEvent_ToolCallDone) Reset() { - *x = StreamEvent_ToolCallDone{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[34] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StreamEvent_ToolCallDone) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StreamEvent_ToolCallDone) ProtoMessage() {} - -func (x *StreamEvent_ToolCallDone) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[34] - 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 StreamEvent_ToolCallDone.ProtoReflect.Descriptor instead. -func (*StreamEvent_ToolCallDone) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{18, 5} -} - -func (x *StreamEvent_ToolCallDone) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -// Stop signals the completion has ended. -type StreamEvent_Stop struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Why the completion ended. - Reason StopReason `protobuf:"varint,1,opt,name=reason,proto3,enum=pluggableharness.model.v1.StopReason" json:"reason,omitempty"` - // Which GenerationParams.stop_sequences entry was matched. Set iff - // reason == STOP_REASON_STOP_SEQUENCE; MUST be omitted for every - // other reason. - MatchedStopSequence *string `protobuf:"bytes,2,opt,name=matched_stop_sequence,json=matchedStopSequence,proto3,oneof" json:"matched_stop_sequence,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StreamEvent_Stop) Reset() { - *x = StreamEvent_Stop{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[35] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StreamEvent_Stop) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StreamEvent_Stop) ProtoMessage() {} - -func (x *StreamEvent_Stop) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[35] - 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 StreamEvent_Stop.ProtoReflect.Descriptor instead. -func (*StreamEvent_Stop) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{18, 6} -} - -func (x *StreamEvent_Stop) GetReason() StopReason { - if x != nil { - return x.Reason - } - return StopReason_STOP_REASON_UNSPECIFIED -} - -func (x *StreamEvent_Stop) GetMatchedStopSequence() string { - if x != nil && x.MatchedStopSequence != nil { - return *x.MatchedStopSequence - } - return "" -} - -// Error signals the completion failed. -type StreamEvent_Error struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The structured error, classified per model.md §8. - Error *ModelError `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StreamEvent_Error) Reset() { - *x = StreamEvent_Error{} - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[36] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StreamEvent_Error) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StreamEvent_Error) ProtoMessage() {} - -func (x *StreamEvent_Error) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_model_proto_msgTypes[36] - 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 StreamEvent_Error.ProtoReflect.Descriptor instead. -func (*StreamEvent_Error) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_model_proto_rawDescGZIP(), []int{18, 7} -} - -func (x *StreamEvent_Error) GetError() *ModelError { - if x != nil { - return x.Error - } - return nil -} - -var File_pluggableharness_model_v1_model_proto protoreflect.FileDescriptor - -const file_pluggableharness_model_v1_model_proto_rawDesc = "" + - "\n" + - "%pluggableharness/model/v1/model.proto\x12\x19pluggableharness.model.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a'pluggableharness/common/v1/common.proto\x1a'pluggableharness/config/v1/config.proto\x1a)pluggableharness/content/v1/content.proto\x1a'pluggableharness/render/v1/render.proto\x1a'pluggableharness/schema/v1/schema.proto\x1a3pluggableharness/slashcommand/v1/slashcommand.proto\"\x18\n" + - "\x16GetCapabilitiesRequest\"f\n" + - "\x17GetCapabilitiesResponse\x12K\n" + - "\fcapabilities\x18\x01 \x01(\v2'.pluggableharness.model.v1.CapabilitiesR\fcapabilities\"\xd1\x02\n" + - "\fCapabilities\x12<\n" + - "\x06models\x18\x01 \x03(\v2$.pluggableharness.model.v1.ModelSpecR\x06models\x12Y\n" + - "\x0eslash_commands\x18\x02 \x03(\v22.pluggableharness.slashcommand.v1.SlashCommandSpecR\rslashCommands\x12M\n" + - "\rconfig_schema\x18\x03 \x01(\v2(.pluggableharness.config.v1.ConfigSchemaR\fconfigSchema\x12Y\n" + - "\x15supported_hook_points\x18\x04 \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPoints\"C\n" + - "\x10ConfigureRequest\x12/\n" + - "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06config\"\x13\n" + - "\x11ConfigureResponse\"\x11\n" + - "\x0fDescribeRequest\"W\n" + - "\x10DescribeResponse\x12C\n" + - "\bproducer\x18\x01 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\bproducer\"\xb7\x05\n" + - "\tModelSpec\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12%\n" + - "\x0econtext_window\x18\x02 \x01(\x03R\rcontextWindow\x12*\n" + - "\x11max_output_tokens\x18\x03 \x01(\x03R\x0fmaxOutputTokens\x12*\n" + - "\x11supports_tool_use\x18\x04 \x01(\bR\x0fsupportsToolUse\x12'\n" + - "\x0fsupports_vision\x18\x05 \x01(\bR\x0esupportsVision\x12-\n" + - "\x12supports_streaming\x18\x06 \x01(\bR\x11supportsStreaming\x12D\n" + - "\x1csupports_parallel_tool_calls\x18\a \x01(\bH\x00R\x19supportsParallelToolCalls\x88\x01\x01\x12C\n" + - "\bthinking\x18\b \x01(\v2'.pluggableharness.model.v1.ThinkingSpecR\bthinking\x12@\n" + - "\acaching\x18\t \x01(\v2&.pluggableharness.model.v1.CachingSpecR\acaching\x12<\n" + - "\apricing\x18\n" + - " \x01(\v2\".pluggableharness.model.v1.PricingR\apricing\x12h\n" + - "\x1bsupported_tool_choice_modes\x18\v \x03(\x0e2).pluggableharness.model.v1.ToolChoiceModeR\x18supportedToolChoiceModes\x12-\n" + - "\x12supports_documents\x18\f \x01(\bR\x11supportsDocumentsB\x1f\n" + - "\x1d_supports_parallel_tool_calls\"9\n" + - "\x13ThinkingBudgetRange\x12\x10\n" + - "\x03min\x18\x01 \x01(\x03R\x03min\x12\x10\n" + - "\x03max\x18\x02 \x01(\x03R\x03max\"\xc3\x02\n" + - "\fThinkingSpec\x12\x1c\n" + - "\tsupported\x18\x01 \x01(\bR\tsupported\x12;\n" + - "\x04mode\x18\x02 \x01(\x0e2'.pluggableharness.model.v1.ThinkingModeR\x04mode\x12#\n" + - "\reffort_levels\x18\x03 \x03(\tR\feffortLevels\x12V\n" + - "\fbudget_range\x18\x04 \x01(\v2..pluggableharness.model.v1.ThinkingBudgetRangeH\x00R\vbudgetRange\x88\x01\x01\x12\x1f\n" + - "\vcan_disable\x18\x05 \x01(\bR\n" + - "canDisable\x12\x1d\n" + - "\adefault\x18\x06 \x01(\tH\x01R\adefault\x88\x01\x01B\x0f\n" + - "\r_budget_rangeB\n" + - "\n" + - "\b_default\"\x98\x01\n" + - "\vCachingSpec\x12\x1c\n" + - "\tsupported\x18\x01 \x01(\bR\tsupported\x12:\n" + - "\x04mode\x18\x02 \x01(\x0e2&.pluggableharness.model.v1.CachingModeR\x04mode\x12/\n" + - "\x13keepalive_supported\x18\x03 \x01(\bR\x12keepaliveSupported\"\xe1\x05\n" + - "\vPricingTier\x12F\n" + - "\x0eeffective_from\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampH\x00R\reffectiveFrom\x88\x01\x01\x12H\n" + - "\x0feffective_until\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampH\x01R\x0eeffectiveUntil\x88\x01\x01\x12$\n" + - "\x0einput_per_mtok\x18\x03 \x01(\x01R\finputPerMtok\x12&\n" + - "\x0foutput_per_mtok\x18\x04 \x01(\x01R\routputPerMtok\x124\n" + - "\x14cache_write_per_mtok\x18\x05 \x01(\x01H\x02R\x11cacheWritePerMtok\x88\x01\x01\x122\n" + - "\x13cache_read_per_mtok\x18\x06 \x01(\x01H\x03R\x10cacheReadPerMtok\x88\x01\x01\x124\n" + - "\x14batch_input_per_mtok\x18\a \x01(\x01H\x04R\x11batchInputPerMtok\x88\x01\x01\x126\n" + - "\x15batch_output_per_mtok\x18\b \x01(\x01H\x05R\x12batchOutputPerMtok\x88\x01\x01\x12/\n" + - "\x11input_tokens_from\x18\t \x01(\x03H\x06R\x0finputTokensFrom\x88\x01\x01\x121\n" + - "\x12input_tokens_until\x18\n" + - " \x01(\x03H\aR\x10inputTokensUntil\x88\x01\x01B\x11\n" + - "\x0f_effective_fromB\x12\n" + - "\x10_effective_untilB\x17\n" + - "\x15_cache_write_per_mtokB\x16\n" + - "\x14_cache_read_per_mtokB\x17\n" + - "\x15_batch_input_per_mtokB\x18\n" + - "\x16_batch_output_per_mtokB\x14\n" + - "\x12_input_tokens_fromB\x15\n" + - "\x13_input_tokens_until\"w\n" + - "\aPricing\x12\x1a\n" + - "\bcurrency\x18\x01 \x01(\tR\bcurrency\x12\x12\n" + - "\x04free\x18\x02 \x01(\bR\x04free\x12<\n" + - "\x05tiers\x18\x03 \x03(\v2&.pluggableharness.model.v1.PricingTierR\x05tiers\"\x8c\x04\n" + - "\x17StreamCompletionRequest\x12@\n" + - "\bmessages\x18\x01 \x03(\v2$.pluggableharness.content.v1.MessageR\bmessages\x12\x19\n" + - "\bmodel_id\x18\x02 \x01(\tR\amodelId\x12@\n" + - "\x05tools\x18\x03 \x03(\v2*.pluggableharness.model.v1.ToolDeclarationR\x05tools\x12H\n" + - "\x06params\x18\x04 \x01(\v2+.pluggableharness.model.v1.GenerationParamsH\x00R\x06params\x88\x01\x01\x12X\n" + - "\x11assembled_context\x18\x05 \x03(\v2+.pluggableharness.content.v1.ContextSectionR\x10assembledContext\x12J\n" + - "\fcall_context\x18\x06 \x01(\v2'.pluggableharness.common.v1.CallContextR\vcallContext\x12W\n" + - "\x11cache_breakpoints\x18\a \x03(\v2*.pluggableharness.model.v1.CacheBreakpointR\x10cacheBreakpointsB\t\n" + - "\a_params\"\xcc\x02\n" + - "\x0fCacheBreakpoint\x12z\n" + - "\x17after_assembled_context\x18\x01 \x01(\v2@.pluggableharness.model.v1.CacheBreakpoint.AfterAssembledContextH\x00R\x15afterAssembledContext\x12X\n" + - "\vafter_tools\x18\x02 \x01(\v25.pluggableharness.model.v1.CacheBreakpoint.AfterToolsH\x00R\n" + - "afterTools\x120\n" + - "\x13after_message_index\x18\x03 \x01(\x03H\x00R\x11afterMessageIndex\x1a\x17\n" + - "\x15AfterAssembledContext\x1a\f\n" + - "\n" + - "AfterToolsB\n" + - "\n" + - "\bposition\"\x8e\x01\n" + - "\x0fToolDeclaration\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + - "\vdescription\x18\x02 \x01(\tR\vdescription\x12E\n" + - "\finput_schema\x18\x03 \x01(\v2\".pluggableharness.schema.v1.SchemaR\vinputSchema\"\xac\x03\n" + - "\x10GenerationParams\x12,\n" + - "\x0fthinking_effort\x18\x01 \x01(\tH\x00R\x0ethinkingEffort\x88\x01\x01\x129\n" + - "\x16thinking_budget_tokens\x18\x02 \x01(\x03H\x01R\x14thinkingBudgetTokens\x88\x01\x01\x12/\n" + - "\x11max_output_tokens\x18\x03 \x01(\x03H\x02R\x0fmaxOutputTokens\x88\x01\x01\x12%\n" + - "\vtemperature\x18\x04 \x01(\x01H\x03R\vtemperature\x88\x01\x01\x12%\n" + - "\x0estop_sequences\x18\x05 \x03(\tR\rstopSequences\x12K\n" + - "\vtool_choice\x18\x06 \x01(\v2%.pluggableharness.model.v1.ToolChoiceH\x04R\n" + - "toolChoice\x88\x01\x01B\x12\n" + - "\x10_thinking_effortB\x19\n" + - "\x17_thinking_budget_tokensB\x14\n" + - "\x12_max_output_tokensB\x0e\n" + - "\f_temperatureB\x0e\n" + - "\f_tool_choice\"{\n" + - "\n" + - "ToolChoice\x12=\n" + - "\x04mode\x18\x01 \x01(\x0e2).pluggableharness.model.v1.ToolChoiceModeR\x04mode\x12 \n" + - "\ttool_name\x18\x02 \x01(\tH\x00R\btoolName\x88\x01\x01B\f\n" + - "\n" + - "_tool_name\"\x92\n" + - "\n" + - "\vStreamEvent\x12Q\n" + - "\n" + - "text_delta\x18\x01 \x01(\v20.pluggableharness.model.v1.StreamEvent.TextDeltaH\x00R\ttextDelta\x12]\n" + - "\x0ethinking_delta\x18\x02 \x01(\v24.pluggableharness.model.v1.StreamEvent.ThinkingDeltaH\x00R\rthinkingDelta\x12i\n" + - "\x12thinking_signature\x18\x03 \x01(\v28.pluggableharness.model.v1.StreamEvent.ThinkingSignatureH\x00R\x11thinkingSignature\x12^\n" + - "\x0ftool_call_start\x18\x04 \x01(\v24.pluggableharness.model.v1.StreamEvent.ToolCallStartH\x00R\rtoolCallStart\x12^\n" + - "\x0ftool_call_delta\x18\x05 \x01(\v24.pluggableharness.model.v1.StreamEvent.ToolCallDeltaH\x00R\rtoolCallDelta\x12[\n" + - "\x0etool_call_done\x18\x06 \x01(\v23.pluggableharness.model.v1.StreamEvent.ToolCallDoneH\x00R\ftoolCallDone\x128\n" + - "\x05usage\x18\a \x01(\v2 .pluggableharness.model.v1.UsageH\x00R\x05usage\x12A\n" + - "\x04stop\x18\b \x01(\v2+.pluggableharness.model.v1.StreamEvent.StopH\x00R\x04stop\x12D\n" + - "\x05error\x18\t \x01(\v2,.pluggableharness.model.v1.StreamEvent.ErrorH\x00R\x05error\x1a\x1f\n" + - "\tTextDelta\x12\x12\n" + - "\x04text\x18\x01 \x01(\tR\x04text\x1a#\n" + - "\rThinkingDelta\x12\x12\n" + - "\x04text\x18\x01 \x01(\tR\x04text\x1a1\n" + - "\x11ThinkingSignature\x12\x1c\n" + - "\tsignature\x18\x01 \x01(\fR\tsignature\x1a3\n" + - "\rToolCallStart\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + - "\x04name\x18\x02 \x01(\tR\x04name\x1aN\n" + - "\rToolCallDelta\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12-\n" + - "\x12arguments_fragment\x18\x02 \x01(\tR\x11argumentsFragment\x1a\x1e\n" + - "\fToolCallDone\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x1a\x98\x01\n" + - "\x04Stop\x12=\n" + - "\x06reason\x18\x01 \x01(\x0e2%.pluggableharness.model.v1.StopReasonR\x06reason\x127\n" + - "\x15matched_stop_sequence\x18\x02 \x01(\tH\x00R\x13matchedStopSequence\x88\x01\x01B\x18\n" + - "\x16_matched_stop_sequence\x1aD\n" + - "\x05Error\x12;\n" + - "\x05error\x18\x01 \x01(\v2%.pluggableharness.model.v1.ModelErrorR\x05errorB\a\n" + - "\x05event\"\xa5\x02\n" + - "\x05Usage\x12!\n" + - "\finput_tokens\x18\x01 \x01(\x03R\vinputTokens\x12#\n" + - "\routput_tokens\x18\x02 \x01(\x03R\foutputTokens\x12/\n" + - "\x11cache_read_tokens\x18\x03 \x01(\x03H\x00R\x0fcacheReadTokens\x88\x01\x01\x121\n" + - "\x12cache_write_tokens\x18\x04 \x01(\x03H\x01R\x10cacheWriteTokens\x88\x01\x01\x12.\n" + - "\x10reasoning_tokens\x18\x05 \x01(\x03H\x02R\x0freasoningTokens\x88\x01\x01B\x14\n" + - "\x12_cache_read_tokensB\x15\n" + - "\x13_cache_write_tokensB\x13\n" + - "\x11_reasoning_tokens\"C\n" + - "\x12CountTokensRequest\x12\x12\n" + - "\x04text\x18\x01 \x01(\tR\x04text\x12\x19\n" + - "\bmodel_id\x18\x02 \x01(\tR\amodelId\"+\n" + - "\x13CountTokensResponse\x12\x14\n" + - "\x05count\x18\x01 \x01(\x03R\x05count\"P\n" + - "\rRenderRequest\x12\x18\n" + - "\apayload\x18\x01 \x01(\fR\apayload\x12%\n" + - "\x0eschema_version\x18\x02 \x01(\tR\rschemaVersion\"L\n" + - "\x0eRenderResponse\x12:\n" + - "\x04tree\x18\x01 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\x04tree\"\x93\x02\n" + - "\n" + - "ModelError\x12I\n" + - "\bcategory\x18\x01 \x01(\x0e2-.pluggableharness.model.v1.ModelErrorCategoryR\bcategory\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\x12\x1c\n" + - "\tretryable\x18\x03 \x01(\bR\tretryable\x12?\n" + - "\vretry_after\x18\x04 \x01(\v2\x19.google.protobuf.DurationH\x00R\n" + - "retryAfter\x88\x01\x01\x12\"\n" + - "\n" + - "raw_detail\x18\x05 \x01(\tH\x01R\trawDetail\x88\x01\x01B\x0e\n" + - "\f_retry_afterB\r\n" + - "\v_raw_detail\"q\n" + - "\vModelTarget\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12%\n" + - "\x0econtext_window\x18\x02 \x01(\x03R\rcontextWindow\x12+\n" + - "\x11effective_ceiling\x18\x03 \x01(\x03R\x10effectiveCeiling\"6\n" + - "\bModelRef\x12\x1a\n" + - "\bprovider\x18\x01 \x01(\tR\bprovider\x12\x0e\n" + - "\x02id\x18\x02 \x01(\tR\x02id*\xb3\x01\n" + - "\fThinkingMode\x12\x1d\n" + - "\x19THINKING_MODE_UNSPECIFIED\x10\x00\x12\x16\n" + - "\x12THINKING_MODE_NONE\x10\x01\x12$\n" + - " THINKING_MODE_ALWAYS_ON_ADAPTIVE\x10\x02\x12!\n" + - "\x1dTHINKING_MODE_DISCRETE_EFFORT\x10\x03\x12#\n" + - "\x1fTHINKING_MODE_CONTINUOUS_BUDGET\x10\x04*\x8a\x01\n" + - "\vCachingMode\x12\x1c\n" + - "\x18CACHING_MODE_UNSPECIFIED\x10\x00\x12\x15\n" + - "\x11CACHING_MODE_NONE\x10\x01\x12!\n" + - "\x1dCACHING_MODE_EXPLICIT_MARKERS\x10\x02\x12#\n" + - "\x1fCACHING_MODE_IMPLICIT_AUTOMATIC\x10\x03*\xa1\x01\n" + - "\x0eToolChoiceMode\x12 \n" + - "\x1cTOOL_CHOICE_MODE_UNSPECIFIED\x10\x00\x12\x19\n" + - "\x15TOOL_CHOICE_MODE_AUTO\x10\x01\x12\x18\n" + - "\x14TOOL_CHOICE_MODE_ANY\x10\x02\x12\x19\n" + - "\x15TOOL_CHOICE_MODE_NONE\x10\x03\x12\x1d\n" + - "\x19TOOL_CHOICE_MODE_SPECIFIC\x10\x04*\xee\x01\n" + - "\n" + - "StopReason\x12\x1b\n" + - "\x17STOP_REASON_UNSPECIFIED\x10\x00\x12\x18\n" + - "\x14STOP_REASON_END_TURN\x10\x01\x12\x18\n" + - "\x14STOP_REASON_TOOL_USE\x10\x02\x12\x1a\n" + - "\x16STOP_REASON_MAX_TOKENS\x10\x03\x12 \n" + - "\x1cSTOP_REASON_CONTENT_FILTERED\x10\x04\x12\x19\n" + - "\x15STOP_REASON_CANCELLED\x10\x05\x12\x17\n" + - "\x13STOP_REASON_REFUSAL\x10\x06\x12\x1d\n" + - "\x19STOP_REASON_STOP_SEQUENCE\x10\a*\xd4\x02\n" + - "\x12ModelErrorCategory\x12$\n" + - " MODEL_ERROR_CATEGORY_UNSPECIFIED\x10\x00\x120\n" + - ",MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED\x10\x01\x12%\n" + - "!MODEL_ERROR_CATEGORY_RATE_LIMITED\x10\x02\x12#\n" + - "\x1fMODEL_ERROR_CATEGORY_OVERLOADED\x10\x03\x12#\n" + - "\x1fMODEL_ERROR_CATEGORY_AUTH_ERROR\x10\x04\x12(\n" + - "$MODEL_ERROR_CATEGORY_INVALID_REQUEST\x10\x05\x12)\n" + - "%MODEL_ERROR_CATEGORY_CONTENT_FILTERED\x10\x06\x12 \n" + - "\x1cMODEL_ERROR_CATEGORY_UNKNOWN\x10\a2\x94\x05\n" + - "\fModelService\x12x\n" + - "\x0fGetCapabilities\x121.pluggableharness.model.v1.GetCapabilitiesRequest\x1a2.pluggableharness.model.v1.GetCapabilitiesResponse\x12f\n" + - "\tConfigure\x12+.pluggableharness.model.v1.ConfigureRequest\x1a,.pluggableharness.model.v1.ConfigureResponse\x12p\n" + - "\x10StreamCompletion\x122.pluggableharness.model.v1.StreamCompletionRequest\x1a&.pluggableharness.model.v1.StreamEvent0\x01\x12l\n" + - "\vCountTokens\x12-.pluggableharness.model.v1.CountTokensRequest\x1a..pluggableharness.model.v1.CountTokensResponse\x12]\n" + - "\x06Render\x12(.pluggableharness.model.v1.RenderRequest\x1a).pluggableharness.model.v1.RenderResponse\x12c\n" + - "\bDescribe\x12*.pluggableharness.model.v1.DescribeRequest\x1a+.pluggableharness.model.v1.DescribeResponseB>Z pluggableharness.model.v1.Capabilities - 12, // 1: pluggableharness.model.v1.Capabilities.models:type_name -> pluggableharness.model.v1.ModelSpec - 42, // 2: pluggableharness.model.v1.Capabilities.slash_commands:type_name -> pluggableharness.slashcommand.v1.SlashCommandSpec - 43, // 3: pluggableharness.model.v1.Capabilities.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema - 44, // 4: pluggableharness.model.v1.Capabilities.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint - 45, // 5: pluggableharness.model.v1.ConfigureRequest.config:type_name -> google.protobuf.Struct - 46, // 6: pluggableharness.model.v1.DescribeResponse.producer:type_name -> pluggableharness.common.v1.ProducerRef - 14, // 7: pluggableharness.model.v1.ModelSpec.thinking:type_name -> pluggableharness.model.v1.ThinkingSpec - 15, // 8: pluggableharness.model.v1.ModelSpec.caching:type_name -> pluggableharness.model.v1.CachingSpec - 17, // 9: pluggableharness.model.v1.ModelSpec.pricing:type_name -> pluggableharness.model.v1.Pricing - 2, // 10: pluggableharness.model.v1.ModelSpec.supported_tool_choice_modes:type_name -> pluggableharness.model.v1.ToolChoiceMode - 0, // 11: pluggableharness.model.v1.ThinkingSpec.mode:type_name -> pluggableharness.model.v1.ThinkingMode - 13, // 12: pluggableharness.model.v1.ThinkingSpec.budget_range:type_name -> pluggableharness.model.v1.ThinkingBudgetRange - 1, // 13: pluggableharness.model.v1.CachingSpec.mode:type_name -> pluggableharness.model.v1.CachingMode - 47, // 14: pluggableharness.model.v1.PricingTier.effective_from:type_name -> google.protobuf.Timestamp - 47, // 15: pluggableharness.model.v1.PricingTier.effective_until:type_name -> google.protobuf.Timestamp - 16, // 16: pluggableharness.model.v1.Pricing.tiers:type_name -> pluggableharness.model.v1.PricingTier - 48, // 17: pluggableharness.model.v1.StreamCompletionRequest.messages:type_name -> pluggableharness.content.v1.Message - 20, // 18: pluggableharness.model.v1.StreamCompletionRequest.tools:type_name -> pluggableharness.model.v1.ToolDeclaration - 21, // 19: pluggableharness.model.v1.StreamCompletionRequest.params:type_name -> pluggableharness.model.v1.GenerationParams - 49, // 20: pluggableharness.model.v1.StreamCompletionRequest.assembled_context:type_name -> pluggableharness.content.v1.ContextSection - 50, // 21: pluggableharness.model.v1.StreamCompletionRequest.call_context:type_name -> pluggableharness.common.v1.CallContext - 19, // 22: pluggableharness.model.v1.StreamCompletionRequest.cache_breakpoints:type_name -> pluggableharness.model.v1.CacheBreakpoint - 32, // 23: pluggableharness.model.v1.CacheBreakpoint.after_assembled_context:type_name -> pluggableharness.model.v1.CacheBreakpoint.AfterAssembledContext - 33, // 24: pluggableharness.model.v1.CacheBreakpoint.after_tools:type_name -> pluggableharness.model.v1.CacheBreakpoint.AfterTools - 51, // 25: pluggableharness.model.v1.ToolDeclaration.input_schema:type_name -> pluggableharness.schema.v1.Schema - 22, // 26: pluggableharness.model.v1.GenerationParams.tool_choice:type_name -> pluggableharness.model.v1.ToolChoice - 2, // 27: pluggableharness.model.v1.ToolChoice.mode:type_name -> pluggableharness.model.v1.ToolChoiceMode - 34, // 28: pluggableharness.model.v1.StreamEvent.text_delta:type_name -> pluggableharness.model.v1.StreamEvent.TextDelta - 35, // 29: pluggableharness.model.v1.StreamEvent.thinking_delta:type_name -> pluggableharness.model.v1.StreamEvent.ThinkingDelta - 36, // 30: pluggableharness.model.v1.StreamEvent.thinking_signature:type_name -> pluggableharness.model.v1.StreamEvent.ThinkingSignature - 37, // 31: pluggableharness.model.v1.StreamEvent.tool_call_start:type_name -> pluggableharness.model.v1.StreamEvent.ToolCallStart - 38, // 32: pluggableharness.model.v1.StreamEvent.tool_call_delta:type_name -> pluggableharness.model.v1.StreamEvent.ToolCallDelta - 39, // 33: pluggableharness.model.v1.StreamEvent.tool_call_done:type_name -> pluggableharness.model.v1.StreamEvent.ToolCallDone - 24, // 34: pluggableharness.model.v1.StreamEvent.usage:type_name -> pluggableharness.model.v1.Usage - 40, // 35: pluggableharness.model.v1.StreamEvent.stop:type_name -> pluggableharness.model.v1.StreamEvent.Stop - 41, // 36: pluggableharness.model.v1.StreamEvent.error:type_name -> pluggableharness.model.v1.StreamEvent.Error - 52, // 37: pluggableharness.model.v1.RenderResponse.tree:type_name -> pluggableharness.render.v1.RenderTree - 4, // 38: pluggableharness.model.v1.ModelError.category:type_name -> pluggableharness.model.v1.ModelErrorCategory - 53, // 39: pluggableharness.model.v1.ModelError.retry_after:type_name -> google.protobuf.Duration - 3, // 40: pluggableharness.model.v1.StreamEvent.Stop.reason:type_name -> pluggableharness.model.v1.StopReason - 29, // 41: pluggableharness.model.v1.StreamEvent.Error.error:type_name -> pluggableharness.model.v1.ModelError - 5, // 42: pluggableharness.model.v1.ModelService.GetCapabilities:input_type -> pluggableharness.model.v1.GetCapabilitiesRequest - 8, // 43: pluggableharness.model.v1.ModelService.Configure:input_type -> pluggableharness.model.v1.ConfigureRequest - 18, // 44: pluggableharness.model.v1.ModelService.StreamCompletion:input_type -> pluggableharness.model.v1.StreamCompletionRequest - 25, // 45: pluggableharness.model.v1.ModelService.CountTokens:input_type -> pluggableharness.model.v1.CountTokensRequest - 27, // 46: pluggableharness.model.v1.ModelService.Render:input_type -> pluggableharness.model.v1.RenderRequest - 10, // 47: pluggableharness.model.v1.ModelService.Describe:input_type -> pluggableharness.model.v1.DescribeRequest - 6, // 48: pluggableharness.model.v1.ModelService.GetCapabilities:output_type -> pluggableharness.model.v1.GetCapabilitiesResponse - 9, // 49: pluggableharness.model.v1.ModelService.Configure:output_type -> pluggableharness.model.v1.ConfigureResponse - 23, // 50: pluggableharness.model.v1.ModelService.StreamCompletion:output_type -> pluggableharness.model.v1.StreamEvent - 26, // 51: pluggableharness.model.v1.ModelService.CountTokens:output_type -> pluggableharness.model.v1.CountTokensResponse - 28, // 52: pluggableharness.model.v1.ModelService.Render:output_type -> pluggableharness.model.v1.RenderResponse - 11, // 53: pluggableharness.model.v1.ModelService.Describe:output_type -> pluggableharness.model.v1.DescribeResponse - 48, // [48:54] is the sub-list for method output_type - 42, // [42:48] is the sub-list for method input_type - 42, // [42:42] is the sub-list for extension type_name - 42, // [42:42] is the sub-list for extension extendee - 0, // [0:42] is the sub-list for field type_name -} - -func init() { file_pluggableharness_model_v1_model_proto_init() } -func file_pluggableharness_model_v1_model_proto_init() { - if File_pluggableharness_model_v1_model_proto != nil { - return - } - file_pluggableharness_model_v1_model_proto_msgTypes[7].OneofWrappers = []any{} - file_pluggableharness_model_v1_model_proto_msgTypes[9].OneofWrappers = []any{} - file_pluggableharness_model_v1_model_proto_msgTypes[11].OneofWrappers = []any{} - file_pluggableharness_model_v1_model_proto_msgTypes[13].OneofWrappers = []any{} - file_pluggableharness_model_v1_model_proto_msgTypes[14].OneofWrappers = []any{ - (*CacheBreakpoint_AfterAssembledContext_)(nil), - (*CacheBreakpoint_AfterTools_)(nil), - (*CacheBreakpoint_AfterMessageIndex)(nil), - } - file_pluggableharness_model_v1_model_proto_msgTypes[16].OneofWrappers = []any{} - file_pluggableharness_model_v1_model_proto_msgTypes[17].OneofWrappers = []any{} - file_pluggableharness_model_v1_model_proto_msgTypes[18].OneofWrappers = []any{ - (*StreamEvent_TextDelta_)(nil), - (*StreamEvent_ThinkingDelta_)(nil), - (*StreamEvent_ThinkingSignature_)(nil), - (*StreamEvent_ToolCallStart_)(nil), - (*StreamEvent_ToolCallDelta_)(nil), - (*StreamEvent_ToolCallDone_)(nil), - (*StreamEvent_Usage)(nil), - (*StreamEvent_Stop_)(nil), - (*StreamEvent_Error_)(nil), - } - file_pluggableharness_model_v1_model_proto_msgTypes[19].OneofWrappers = []any{} - file_pluggableharness_model_v1_model_proto_msgTypes[24].OneofWrappers = []any{} - file_pluggableharness_model_v1_model_proto_msgTypes[35].OneofWrappers = []any{} - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_model_v1_model_proto_rawDesc), len(file_pluggableharness_model_v1_model_proto_rawDesc)), - NumEnums: 5, - NumMessages: 37, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_pluggableharness_model_v1_model_proto_goTypes, - DependencyIndexes: file_pluggableharness_model_v1_model_proto_depIdxs, - EnumInfos: file_pluggableharness_model_v1_model_proto_enumTypes, - MessageInfos: file_pluggableharness_model_v1_model_proto_msgTypes, - }.Build() - File_pluggableharness_model_v1_model_proto = out.File - file_pluggableharness_model_v1_model_proto_goTypes = nil - file_pluggableharness_model_v1_model_proto_depIdxs = nil -} diff --git a/pkg/model/proto/v1/rpc_request.pb.go b/pkg/model/proto/v1/rpc_request.pb.go new file mode 100644 index 0000000..2cf50b7 --- /dev/null +++ b/pkg/model/proto/v1/rpc_request.pb.go @@ -0,0 +1,504 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/model/v1/rpc_request.proto + +package modelv1 + +import ( + v11 "github.com/pluggableharness/agent/pkg/common/proto/v1" + v1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// GetCapabilitiesRequest is empty: model.md §2 defines GetCapabilities +// as taking no request parameters. +type GetCapabilitiesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCapabilitiesRequest) Reset() { + *x = GetCapabilitiesRequest{} + mi := &file_pluggableharness_model_v1_rpc_request_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCapabilitiesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCapabilitiesRequest) ProtoMessage() {} + +func (x *GetCapabilitiesRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_rpc_request_proto_msgTypes[0] + 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 GetCapabilitiesRequest.ProtoReflect.Descriptor instead. +func (*GetCapabilitiesRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_rpc_request_proto_rawDescGZIP(), []int{0} +} + +// ConfigureRequest wraps the provider's agent.hcl config block, already +// decoded from HCL/cty into a Struct by the kernel's schema-to-cty bridge, +// per model.md §3. +type ConfigureRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The decoded config value. Field contents are provider-specific (API + // key, base URL override, org/project IDs, etc.) — model.md §3 + // doesn't mandate a shape beyond what ConfigSchema (Capabilities. + // config_schema) declares. A Struct because the shape is genuinely + // provider-defined, not fixed at the proto level (see + // .claude/rules/proto.md's Struct carve-out). + Config *structpb.Struct `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureRequest) Reset() { + *x = ConfigureRequest{} + mi := &file_pluggableharness_model_v1_rpc_request_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureRequest) ProtoMessage() {} + +func (x *ConfigureRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_rpc_request_proto_msgTypes[1] + 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 ConfigureRequest.ProtoReflect.Descriptor instead. +func (*ConfigureRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_rpc_request_proto_rawDescGZIP(), []int{1} +} + +func (x *ConfigureRequest) GetConfig() *structpb.Struct { + if x != nil { + return x.Config + } + return nil +} + +// DescribeRequest is empty: Describe takes no request parameters, per +// configuration/lock-file.md's dev_overrides note. +type DescribeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeRequest) Reset() { + *x = DescribeRequest{} + mi := &file_pluggableharness_model_v1_rpc_request_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeRequest) ProtoMessage() {} + +func (x *DescribeRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_rpc_request_proto_msgTypes[2] + 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 DescribeRequest.ProtoReflect.Descriptor instead. +func (*DescribeRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_rpc_request_proto_rawDescGZIP(), []int{2} +} + +// StreamCompletionRequest is StreamCompletion's request: the full +// canonical conversation, available tools, and generation params for one +// completion, per model.md §4. +type StreamCompletionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The canonical conversation history, in emission order (model.md + // §5). + Messages []*v1.Message `protobuf:"bytes,1,rep,name=messages,proto3" json:"messages,omitempty"` + // Selects which of this provider's ModelSpec.id to use. + ModelId string `protobuf:"bytes,2,opt,name=model_id,json=modelId,proto3" json:"model_id,omitempty"` + // The tools available to the model on this turn, described in the + // shared JSON-Schema subset (model.md §6). MAY be empty. + Tools []*ToolDeclaration `protobuf:"bytes,3,rep,name=tools,proto3" json:"tools,omitempty"` + // Generation-time overrides. Omitted means every param takes its + // model-specific default. + Params *GenerationParams `protobuf:"bytes,4,opt,name=params,proto3,oneof" json:"params,omitempty"` + // The kernel-assembled context chain — the accumulated output of every + // context provider's Contribute call plus memory recall + // (context/protocol.md#contribute-the-context-assemble-rpc), in chain + // order (the same order context.md's ContextRequest.prior_sections/ + // Contribute response chain uses: each provider appends after the + // sections it received). This is distinct from `messages` above: it is + // system-level/preamble content, never a conversational turn, which is + // why it's carried as its own field rather than folded into `messages` + // as a synthetic message — content.v1.Role deliberately has no SYSTEM + // value (content/v1/types.proto's Role comment) precisely because this content + // is never a Message with a role. Each model-provider adapter maps + // this chain to its vendor's own system/preamble mechanism (a top-level + // `system` string, a leading system-role message, etc.) — how that + // mapping happens is adapter-internal and not part of this wire + // contract. + AssembledContext []*v1.ContextSection `protobuf:"bytes,5,rep,name=assembled_context,json=assembledContext,proto3" json:"assembled_context,omitempty"` + // Session/turn/working-directory attribution for this call. MUST be set + // by the kernel on every StreamCompletionRequest. This is what the + // plugin passes back on its own KernelCallbackService.Emit and Log + // calls (kernel-callbacks.md#emit) for correlation, without having to + // separately thread session_id/turn_id through adapter-internal call + // sites by hand. + CallContext *v11.CallContext `protobuf:"bytes,6,opt,name=call_context,json=callContext,proto3" json:"call_context,omitempty"` + // Cache breakpoints for this request, wire-level and request-scoped — + // NOT carried on the persisted content.v1.ContentBlock, since a + // breakpoint's placement is a per-request optimization decision, not a + // durable property of the conversation history itself. Meaningful only + // when the target model's CachingSpec.mode == + // CACHING_MODE_EXPLICIT_MARKERS; a model-provider adapter targeting a + // model whose CachingSpec.mode is CACHING_MODE_IMPLICIT_AUTOMATIC or + // CACHING_MODE_NONE MUST ignore this field rather than error on it. + // Placement is a kernel decision, not the plugin's: the kernel knows + // each assembled_context section's Stability (content/v1/types.proto's Stability + // enum) and each message's position, so it places breakpoints at + // natural stable-prefix boundaries — see + // model/protocol.md#cache-breakpoint-placement-policy. + CacheBreakpoints []*CacheBreakpoint `protobuf:"bytes,7,rep,name=cache_breakpoints,json=cacheBreakpoints,proto3" json:"cache_breakpoints,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamCompletionRequest) Reset() { + *x = StreamCompletionRequest{} + mi := &file_pluggableharness_model_v1_rpc_request_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamCompletionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamCompletionRequest) ProtoMessage() {} + +func (x *StreamCompletionRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_rpc_request_proto_msgTypes[3] + 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 StreamCompletionRequest.ProtoReflect.Descriptor instead. +func (*StreamCompletionRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_rpc_request_proto_rawDescGZIP(), []int{3} +} + +func (x *StreamCompletionRequest) GetMessages() []*v1.Message { + if x != nil { + return x.Messages + } + return nil +} + +func (x *StreamCompletionRequest) GetModelId() string { + if x != nil { + return x.ModelId + } + return "" +} + +func (x *StreamCompletionRequest) GetTools() []*ToolDeclaration { + if x != nil { + return x.Tools + } + return nil +} + +func (x *StreamCompletionRequest) GetParams() *GenerationParams { + if x != nil { + return x.Params + } + return nil +} + +func (x *StreamCompletionRequest) GetAssembledContext() []*v1.ContextSection { + if x != nil { + return x.AssembledContext + } + return nil +} + +func (x *StreamCompletionRequest) GetCallContext() *v11.CallContext { + if x != nil { + return x.CallContext + } + return nil +} + +func (x *StreamCompletionRequest) GetCacheBreakpoints() []*CacheBreakpoint { + if x != nil { + return x.CacheBreakpoints + } + return nil +} + +// CountTokensRequest is CountTokens' request: the raw text to count, per +// model.md §2.1. +type CountTokensRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The text to count tokens for. + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` + // Selects which of this provider's ModelSpec.id to count against — a + // provider serving several models MAY have distinct tokenizers per + // model. MUST be set. + ModelId string `protobuf:"bytes,2,opt,name=model_id,json=modelId,proto3" json:"model_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CountTokensRequest) Reset() { + *x = CountTokensRequest{} + mi := &file_pluggableharness_model_v1_rpc_request_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CountTokensRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CountTokensRequest) ProtoMessage() {} + +func (x *CountTokensRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_rpc_request_proto_msgTypes[4] + 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 CountTokensRequest.ProtoReflect.Descriptor instead. +func (*CountTokensRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_rpc_request_proto_rawDescGZIP(), []int{4} +} + +func (x *CountTokensRequest) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +func (x *CountTokensRequest) GetModelId() string { + if x != nil { + return x.ModelId + } + return "" +} + +// RenderRequest carries the opaque payload to render, per model.md §7. +type RenderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The opaque emitted payload to render — the Emit->Render->Paint + // pipeline's deliberate carve-out from the strong-typing rule (see + // .claude/rules/grpc.md), never interpreted by the kernel. + Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` + // The schema_version `payload` was emitted under, per + // ../frontend/render-tree.md#schema-versioning. MUST be set — lets this + // Render implementation interpret a payload emitted by an older plugin + // version consistently across a replayed session, the same + // "supersedes" reasoning architecture.md applies elsewhere to + // schema-drift-sensitive persisted data. + SchemaVersion string `protobuf:"bytes,2,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenderRequest) Reset() { + *x = RenderRequest{} + mi := &file_pluggableharness_model_v1_rpc_request_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenderRequest) ProtoMessage() {} + +func (x *RenderRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_rpc_request_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 RenderRequest.ProtoReflect.Descriptor instead. +func (*RenderRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_rpc_request_proto_rawDescGZIP(), []int{5} +} + +func (x *RenderRequest) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +func (x *RenderRequest) GetSchemaVersion() string { + if x != nil { + return x.SchemaVersion + } + return "" +} + +var File_pluggableharness_model_v1_rpc_request_proto protoreflect.FileDescriptor + +const file_pluggableharness_model_v1_rpc_request_proto_rawDesc = "" + + "\n" + + "+pluggableharness/model/v1/rpc_request.proto\x12\x19pluggableharness.model.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a&pluggableharness/common/v1/types.proto\x1a'pluggableharness/content/v1/types.proto\x1a%pluggableharness/model/v1/types.proto\"\x18\n" + + "\x16GetCapabilitiesRequest\"C\n" + + "\x10ConfigureRequest\x12/\n" + + "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06config\"\x11\n" + + "\x0fDescribeRequest\"\x8c\x04\n" + + "\x17StreamCompletionRequest\x12@\n" + + "\bmessages\x18\x01 \x03(\v2$.pluggableharness.content.v1.MessageR\bmessages\x12\x19\n" + + "\bmodel_id\x18\x02 \x01(\tR\amodelId\x12@\n" + + "\x05tools\x18\x03 \x03(\v2*.pluggableharness.model.v1.ToolDeclarationR\x05tools\x12H\n" + + "\x06params\x18\x04 \x01(\v2+.pluggableharness.model.v1.GenerationParamsH\x00R\x06params\x88\x01\x01\x12X\n" + + "\x11assembled_context\x18\x05 \x03(\v2+.pluggableharness.content.v1.ContextSectionR\x10assembledContext\x12J\n" + + "\fcall_context\x18\x06 \x01(\v2'.pluggableharness.common.v1.CallContextR\vcallContext\x12W\n" + + "\x11cache_breakpoints\x18\a \x03(\v2*.pluggableharness.model.v1.CacheBreakpointR\x10cacheBreakpointsB\t\n" + + "\a_params\"C\n" + + "\x12CountTokensRequest\x12\x12\n" + + "\x04text\x18\x01 \x01(\tR\x04text\x12\x19\n" + + "\bmodel_id\x18\x02 \x01(\tR\amodelId\"P\n" + + "\rRenderRequest\x12\x18\n" + + "\apayload\x18\x01 \x01(\fR\apayload\x12%\n" + + "\x0eschema_version\x18\x02 \x01(\tR\rschemaVersionB>Z google.protobuf.Struct + 7, // 1: pluggableharness.model.v1.StreamCompletionRequest.messages:type_name -> pluggableharness.content.v1.Message + 8, // 2: pluggableharness.model.v1.StreamCompletionRequest.tools:type_name -> pluggableharness.model.v1.ToolDeclaration + 9, // 3: pluggableharness.model.v1.StreamCompletionRequest.params:type_name -> pluggableharness.model.v1.GenerationParams + 10, // 4: pluggableharness.model.v1.StreamCompletionRequest.assembled_context:type_name -> pluggableharness.content.v1.ContextSection + 11, // 5: pluggableharness.model.v1.StreamCompletionRequest.call_context:type_name -> pluggableharness.common.v1.CallContext + 12, // 6: pluggableharness.model.v1.StreamCompletionRequest.cache_breakpoints:type_name -> pluggableharness.model.v1.CacheBreakpoint + 7, // [7:7] is the sub-list for method output_type + 7, // [7:7] 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 +} + +func init() { file_pluggableharness_model_v1_rpc_request_proto_init() } +func file_pluggableharness_model_v1_rpc_request_proto_init() { + if File_pluggableharness_model_v1_rpc_request_proto != nil { + return + } + file_pluggableharness_model_v1_types_proto_init() + file_pluggableharness_model_v1_rpc_request_proto_msgTypes[3].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_model_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_model_v1_rpc_request_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_model_v1_rpc_request_proto_goTypes, + DependencyIndexes: file_pluggableharness_model_v1_rpc_request_proto_depIdxs, + MessageInfos: file_pluggableharness_model_v1_rpc_request_proto_msgTypes, + }.Build() + File_pluggableharness_model_v1_rpc_request_proto = out.File + file_pluggableharness_model_v1_rpc_request_proto_goTypes = nil + file_pluggableharness_model_v1_rpc_request_proto_depIdxs = nil +} diff --git a/pkg/model/proto/v1/rpc_response.pb.go b/pkg/model/proto/v1/rpc_response.pb.go new file mode 100644 index 0000000..48a9741 --- /dev/null +++ b/pkg/model/proto/v1/rpc_response.pb.go @@ -0,0 +1,328 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/model/v1/rpc_response.proto + +package modelv1 + +import ( + v1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + v11 "github.com/pluggableharness/agent/pkg/render/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// GetCapabilitiesResponse wraps Capabilities for the RPC signature, per +// this repo's per-RPC envelope convention (.claude/rules/proto.md). +type GetCapabilitiesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Capabilities *Capabilities `protobuf:"bytes,1,opt,name=capabilities,proto3" json:"capabilities,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCapabilitiesResponse) Reset() { + *x = GetCapabilitiesResponse{} + mi := &file_pluggableharness_model_v1_rpc_response_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCapabilitiesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCapabilitiesResponse) ProtoMessage() {} + +func (x *GetCapabilitiesResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_rpc_response_proto_msgTypes[0] + 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 GetCapabilitiesResponse.ProtoReflect.Descriptor instead. +func (*GetCapabilitiesResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_rpc_response_proto_rawDescGZIP(), []int{0} +} + +func (x *GetCapabilitiesResponse) GetCapabilities() *Capabilities { + if x != nil { + return x.Capabilities + } + return nil +} + +// ConfigureResponse is empty on success. A Configure failure (e.g. a +// missing required field) surfaces as a gRPC status carrying a +// ModelError in its structured detail, per .claude/rules/grpc.md's +// error-taxonomy convention — there is no in-band error field on this +// message. +type ConfigureResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureResponse) Reset() { + *x = ConfigureResponse{} + mi := &file_pluggableharness_model_v1_rpc_response_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureResponse) ProtoMessage() {} + +func (x *ConfigureResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_rpc_response_proto_msgTypes[1] + 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 ConfigureResponse.ProtoReflect.Descriptor instead. +func (*ConfigureResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_rpc_response_proto_rawDescGZIP(), []int{1} +} + +// DescribeResponse reports this plugin build's own identity, per +// configuration/lock-file.md's dev_overrides note. +type DescribeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // This plugin build's identity, as it would otherwise appear in a + // lock-file provider "" { ... } entry. + Producer *v1.ProducerRef `protobuf:"bytes,1,opt,name=producer,proto3" json:"producer,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeResponse) Reset() { + *x = DescribeResponse{} + mi := &file_pluggableharness_model_v1_rpc_response_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeResponse) ProtoMessage() {} + +func (x *DescribeResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_rpc_response_proto_msgTypes[2] + 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 DescribeResponse.ProtoReflect.Descriptor instead. +func (*DescribeResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_rpc_response_proto_rawDescGZIP(), []int{2} +} + +func (x *DescribeResponse) GetProducer() *v1.ProducerRef { + if x != nil { + return x.Producer + } + return nil +} + +// CountTokensResponse is CountTokens' response. +type CountTokensResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The exact token count, per this model's real vendor tokenizer. + Count int64 `protobuf:"varint,1,opt,name=count,proto3" json:"count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CountTokensResponse) Reset() { + *x = CountTokensResponse{} + mi := &file_pluggableharness_model_v1_rpc_response_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CountTokensResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CountTokensResponse) ProtoMessage() {} + +func (x *CountTokensResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_rpc_response_proto_msgTypes[3] + 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 CountTokensResponse.ProtoReflect.Descriptor instead. +func (*CountTokensResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_rpc_response_proto_rawDescGZIP(), []int{3} +} + +func (x *CountTokensResponse) GetCount() int64 { + if x != nil { + return x.Count + } + return 0 +} + +// RenderResponse wraps the resulting RenderTree, per model.md §7. +type RenderResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The rendered tree, formally defined in frontend.md §1 and shared + // verbatim across every category's Render RPC (tool.md §7, context.md + // §9, memory.md §10) — one RenderTree type for the whole + // Emit->Render->Paint pipeline, not a per-category variant. + Tree *v11.RenderTree `protobuf:"bytes,1,opt,name=tree,proto3" json:"tree,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenderResponse) Reset() { + *x = RenderResponse{} + mi := &file_pluggableharness_model_v1_rpc_response_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenderResponse) ProtoMessage() {} + +func (x *RenderResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_rpc_response_proto_msgTypes[4] + 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 RenderResponse.ProtoReflect.Descriptor instead. +func (*RenderResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_rpc_response_proto_rawDescGZIP(), []int{4} +} + +func (x *RenderResponse) GetTree() *v11.RenderTree { + if x != nil { + return x.Tree + } + return nil +} + +var File_pluggableharness_model_v1_rpc_response_proto protoreflect.FileDescriptor + +const file_pluggableharness_model_v1_rpc_response_proto_rawDesc = "" + + "\n" + + ",pluggableharness/model/v1/rpc_response.proto\x12\x19pluggableharness.model.v1\x1a&pluggableharness/common/v1/types.proto\x1a%pluggableharness/model/v1/types.proto\x1a&pluggableharness/render/v1/types.proto\"f\n" + + "\x17GetCapabilitiesResponse\x12K\n" + + "\fcapabilities\x18\x01 \x01(\v2'.pluggableharness.model.v1.CapabilitiesR\fcapabilities\"\x13\n" + + "\x11ConfigureResponse\"W\n" + + "\x10DescribeResponse\x12C\n" + + "\bproducer\x18\x01 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\bproducer\"+\n" + + "\x13CountTokensResponse\x12\x14\n" + + "\x05count\x18\x01 \x01(\x03R\x05count\"L\n" + + "\x0eRenderResponse\x12:\n" + + "\x04tree\x18\x01 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\x04treeB>Z pluggableharness.model.v1.Capabilities + 6, // 1: pluggableharness.model.v1.DescribeResponse.producer:type_name -> pluggableharness.common.v1.ProducerRef + 7, // 2: pluggableharness.model.v1.RenderResponse.tree:type_name -> pluggableharness.render.v1.RenderTree + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_pluggableharness_model_v1_rpc_response_proto_init() } +func file_pluggableharness_model_v1_rpc_response_proto_init() { + if File_pluggableharness_model_v1_rpc_response_proto != nil { + return + } + file_pluggableharness_model_v1_types_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_model_v1_rpc_response_proto_rawDesc), len(file_pluggableharness_model_v1_rpc_response_proto_rawDesc)), + NumEnums: 0, + NumMessages: 5, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_model_v1_rpc_response_proto_goTypes, + DependencyIndexes: file_pluggableharness_model_v1_rpc_response_proto_depIdxs, + MessageInfos: file_pluggableharness_model_v1_rpc_response_proto_msgTypes, + }.Build() + File_pluggableharness_model_v1_rpc_response_proto = out.File + file_pluggableharness_model_v1_rpc_response_proto_goTypes = nil + file_pluggableharness_model_v1_rpc_response_proto_depIdxs = nil +} diff --git a/pkg/model/proto/v1/service.pb.go b/pkg/model/proto/v1/service.pb.go new file mode 100644 index 0000000..54781d0 --- /dev/null +++ b/pkg/model/proto/v1/service.pb.go @@ -0,0 +1,104 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/model/v1/service.proto + +// Package pluggableharness.model.v1 defines the model (LLM vendor) provider +// plugin protocol described in specifications/model.md — see +// .claude/rules/proto.md — plus the two distinct model-identity shapes used +// across the other specs. The identity shapes are deliberately NOT unified +// into one message: ModelTarget is a rich "what am I generating context +// for" descriptor (context.md §4, memory.md §6); ModelRef is a narrow +// "which model's tokenizer" selector (kernel-callbacks.md §2). Merging them +// would force every CountTokens caller to populate fields it doesn't have +// and doesn't need. + +package modelv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_pluggableharness_model_v1_service_proto protoreflect.FileDescriptor + +const file_pluggableharness_model_v1_service_proto_rawDesc = "" + + "\n" + + "'pluggableharness/model/v1/service.proto\x12\x19pluggableharness.model.v1\x1a&pluggableharness/model/v1/events.proto\x1a+pluggableharness/model/v1/rpc_request.proto\x1a,pluggableharness/model/v1/rpc_response.proto2\x94\x05\n" + + "\fModelService\x12x\n" + + "\x0fGetCapabilities\x121.pluggableharness.model.v1.GetCapabilitiesRequest\x1a2.pluggableharness.model.v1.GetCapabilitiesResponse\x12f\n" + + "\tConfigure\x12+.pluggableharness.model.v1.ConfigureRequest\x1a,.pluggableharness.model.v1.ConfigureResponse\x12p\n" + + "\x10StreamCompletion\x122.pluggableharness.model.v1.StreamCompletionRequest\x1a&.pluggableharness.model.v1.StreamEvent0\x01\x12l\n" + + "\vCountTokens\x12-.pluggableharness.model.v1.CountTokensRequest\x1a..pluggableharness.model.v1.CountTokensResponse\x12]\n" + + "\x06Render\x12(.pluggableharness.model.v1.RenderRequest\x1a).pluggableharness.model.v1.RenderResponse\x12c\n" + + "\bDescribe\x12*.pluggableharness.model.v1.DescribeRequest\x1a+.pluggableharness.model.v1.DescribeResponseB>Z pluggableharness.model.v1.GetCapabilitiesRequest + 1, // 1: pluggableharness.model.v1.ModelService.Configure:input_type -> pluggableharness.model.v1.ConfigureRequest + 2, // 2: pluggableharness.model.v1.ModelService.StreamCompletion:input_type -> pluggableharness.model.v1.StreamCompletionRequest + 3, // 3: pluggableharness.model.v1.ModelService.CountTokens:input_type -> pluggableharness.model.v1.CountTokensRequest + 4, // 4: pluggableharness.model.v1.ModelService.Render:input_type -> pluggableharness.model.v1.RenderRequest + 5, // 5: pluggableharness.model.v1.ModelService.Describe:input_type -> pluggableharness.model.v1.DescribeRequest + 6, // 6: pluggableharness.model.v1.ModelService.GetCapabilities:output_type -> pluggableharness.model.v1.GetCapabilitiesResponse + 7, // 7: pluggableharness.model.v1.ModelService.Configure:output_type -> pluggableharness.model.v1.ConfigureResponse + 8, // 8: pluggableharness.model.v1.ModelService.StreamCompletion:output_type -> pluggableharness.model.v1.StreamEvent + 9, // 9: pluggableharness.model.v1.ModelService.CountTokens:output_type -> pluggableharness.model.v1.CountTokensResponse + 10, // 10: pluggableharness.model.v1.ModelService.Render:output_type -> pluggableharness.model.v1.RenderResponse + 11, // 11: pluggableharness.model.v1.ModelService.Describe:output_type -> pluggableharness.model.v1.DescribeResponse + 6, // [6:12] is the sub-list for method output_type + 0, // [0:6] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_pluggableharness_model_v1_service_proto_init() } +func file_pluggableharness_model_v1_service_proto_init() { + if File_pluggableharness_model_v1_service_proto != nil { + return + } + file_pluggableharness_model_v1_events_proto_init() + file_pluggableharness_model_v1_rpc_request_proto_init() + file_pluggableharness_model_v1_rpc_response_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_model_v1_service_proto_rawDesc), len(file_pluggableharness_model_v1_service_proto_rawDesc)), + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_pluggableharness_model_v1_service_proto_goTypes, + DependencyIndexes: file_pluggableharness_model_v1_service_proto_depIdxs, + }.Build() + File_pluggableharness_model_v1_service_proto = out.File + file_pluggableharness_model_v1_service_proto_goTypes = nil + file_pluggableharness_model_v1_service_proto_depIdxs = nil +} diff --git a/pkg/model/proto/v1/model_grpc.pb.go b/pkg/model/proto/v1/service_grpc.pb.go similarity index 98% rename from pkg/model/proto/v1/model_grpc.pb.go rename to pkg/model/proto/v1/service_grpc.pb.go index f3f60f5..1547e2e 100644 --- a/pkg/model/proto/v1/model_grpc.pb.go +++ b/pkg/model/proto/v1/service_grpc.pb.go @@ -2,7 +2,7 @@ // versions: // - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) -// source: pluggableharness/model/v1/model.proto +// source: pluggableharness/model/v1/service.proto // Package pluggableharness.model.v1 defines the model (LLM vendor) provider // plugin protocol described in specifications/model.md — see @@ -100,7 +100,7 @@ type ModelServiceClient interface { // the registry/lock-file resolution path entirely and so has no // provider "" { ... } lock entry to read identity from; see // configuration/lock-file.md's dev_overrides note for the canonical - // explanation, shared verbatim across all six category protocols that + // explanation, shared verbatim across all seven category protocols that // gain this RPC in this same protocol revision. Describe(ctx context.Context, in *DescribeRequest, opts ...grpc.CallOption) (*DescribeResponse, error) } @@ -245,7 +245,7 @@ type ModelServiceServer interface { // the registry/lock-file resolution path entirely and so has no // provider "" { ... } lock entry to read identity from; see // configuration/lock-file.md's dev_overrides note for the canonical - // explanation, shared verbatim across all six category protocols that + // explanation, shared verbatim across all seven category protocols that // gain this RPC in this same protocol revision. Describe(context.Context, *DescribeRequest) (*DescribeResponse, error) mustEmbedUnimplementedModelServiceServer() @@ -433,5 +433,5 @@ var ModelService_ServiceDesc = grpc.ServiceDesc{ ServerStreams: true, }, }, - Metadata: "pluggableharness/model/v1/model.proto", + Metadata: "pluggableharness/model/v1/service.proto", } diff --git a/pkg/model/proto/v1/types.pb.go b/pkg/model/proto/v1/types.pb.go new file mode 100644 index 0000000..1a8907e --- /dev/null +++ b/pkg/model/proto/v1/types.pb.go @@ -0,0 +1,1831 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/model/v1/types.proto + +package modelv1 + +import ( + v1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + v11 "github.com/pluggableharness/agent/pkg/config/proto/v1" + v12 "github.com/pluggableharness/agent/pkg/schema/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// ThinkingMode enumerates the shapes of extended-reasoning control found +// across researched vendors (model.md §2) — a plain supports_thinking +// bool would lose information the kernel needs to build a correct +// request, since some vendors (e.g. Anthropic) expose more than one mode +// across their own model lineup. +type ThinkingMode int32 + +const ( + // Zero value. Never valid when ThinkingSpec.supported is true; its + // presence on the wire means a caller forgot to set the field. + ThinkingMode_THINKING_MODE_UNSPECIFIED ThinkingMode = 0 + // The model has no extended-reasoning capability. Pairs with + // ThinkingSpec.supported == false. + ThinkingMode_THINKING_MODE_NONE ThinkingMode = 1 + // The model always reasons, adaptively, with no caller-selectable + // effort level or budget. + ThinkingMode_THINKING_MODE_ALWAYS_ON_ADAPTIVE ThinkingMode = 2 + // The caller selects one of a fixed set of named effort levels + // (ThinkingSpec.effort_levels). + ThinkingMode_THINKING_MODE_DISCRETE_EFFORT ThinkingMode = 3 + // The caller selects a token budget within ThinkingSpec.budget_range. + ThinkingMode_THINKING_MODE_CONTINUOUS_BUDGET ThinkingMode = 4 +) + +// Enum value maps for ThinkingMode. +var ( + ThinkingMode_name = map[int32]string{ + 0: "THINKING_MODE_UNSPECIFIED", + 1: "THINKING_MODE_NONE", + 2: "THINKING_MODE_ALWAYS_ON_ADAPTIVE", + 3: "THINKING_MODE_DISCRETE_EFFORT", + 4: "THINKING_MODE_CONTINUOUS_BUDGET", + } + ThinkingMode_value = map[string]int32{ + "THINKING_MODE_UNSPECIFIED": 0, + "THINKING_MODE_NONE": 1, + "THINKING_MODE_ALWAYS_ON_ADAPTIVE": 2, + "THINKING_MODE_DISCRETE_EFFORT": 3, + "THINKING_MODE_CONTINUOUS_BUDGET": 4, + } +) + +func (x ThinkingMode) Enum() *ThinkingMode { + p := new(ThinkingMode) + *p = x + return p +} + +func (x ThinkingMode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ThinkingMode) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_model_v1_types_proto_enumTypes[0].Descriptor() +} + +func (ThinkingMode) Type() protoreflect.EnumType { + return &file_pluggableharness_model_v1_types_proto_enumTypes[0] +} + +func (x ThinkingMode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ThinkingMode.Descriptor instead. +func (ThinkingMode) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{0} +} + +// CachingMode enumerates the prompt-caching mechanics found across +// researched vendors (model.md §2). +type CachingMode int32 + +const ( + // Zero value. Never valid when CachingSpec.supported is true; its + // presence on the wire means a caller forgot to set the field. + CachingMode_CACHING_MODE_UNSPECIFIED CachingMode = 0 + // The model has no prompt-caching capability. Pairs with + // CachingSpec.supported == false. + CachingMode_CACHING_MODE_NONE CachingMode = 1 + // The caller must place cache breakpoints on content blocks explicitly + // (Anthropic/Mistral-style). + CachingMode_CACHING_MODE_EXPLICIT_MARKERS CachingMode = 2 + // The vendor applies caching transparently above a token threshold, no + // caller action required. + CachingMode_CACHING_MODE_IMPLICIT_AUTOMATIC CachingMode = 3 +) + +// Enum value maps for CachingMode. +var ( + CachingMode_name = map[int32]string{ + 0: "CACHING_MODE_UNSPECIFIED", + 1: "CACHING_MODE_NONE", + 2: "CACHING_MODE_EXPLICIT_MARKERS", + 3: "CACHING_MODE_IMPLICIT_AUTOMATIC", + } + CachingMode_value = map[string]int32{ + "CACHING_MODE_UNSPECIFIED": 0, + "CACHING_MODE_NONE": 1, + "CACHING_MODE_EXPLICIT_MARKERS": 2, + "CACHING_MODE_IMPLICIT_AUTOMATIC": 3, + } +) + +func (x CachingMode) Enum() *CachingMode { + p := new(CachingMode) + *p = x + return p +} + +func (x CachingMode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (CachingMode) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_model_v1_types_proto_enumTypes[1].Descriptor() +} + +func (CachingMode) Type() protoreflect.EnumType { + return &file_pluggableharness_model_v1_types_proto_enumTypes[1] +} + +func (x CachingMode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use CachingMode.Descriptor instead. +func (CachingMode) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{1} +} + +// ToolChoiceMode enumerates the tool-invocation constraint shapes found +// across researched vendors, per the same "declare precisely, don't +// collapse to a bool" reasoning as ThinkingMode/CachingMode above. +type ToolChoiceMode int32 + +const ( + // Zero value. Never valid on a real ToolChoice; its presence on the + // wire means a caller forgot to set the field. + ToolChoiceMode_TOOL_CHOICE_MODE_UNSPECIFIED ToolChoiceMode = 0 + // The model decides freely whether and which tool to call. Equivalent + // to omitting GenerationParams.tool_choice entirely. + ToolChoiceMode_TOOL_CHOICE_MODE_AUTO ToolChoiceMode = 1 + // The model MUST call some tool this turn, but may pick which one. + ToolChoiceMode_TOOL_CHOICE_MODE_ANY ToolChoiceMode = 2 + // The model MUST NOT call any tool this turn, even if tools were + // declared. + ToolChoiceMode_TOOL_CHOICE_MODE_NONE ToolChoiceMode = 3 + // The model MUST call the specific tool named in ToolChoice.tool_name. + ToolChoiceMode_TOOL_CHOICE_MODE_SPECIFIC ToolChoiceMode = 4 +) + +// Enum value maps for ToolChoiceMode. +var ( + ToolChoiceMode_name = map[int32]string{ + 0: "TOOL_CHOICE_MODE_UNSPECIFIED", + 1: "TOOL_CHOICE_MODE_AUTO", + 2: "TOOL_CHOICE_MODE_ANY", + 3: "TOOL_CHOICE_MODE_NONE", + 4: "TOOL_CHOICE_MODE_SPECIFIC", + } + ToolChoiceMode_value = map[string]int32{ + "TOOL_CHOICE_MODE_UNSPECIFIED": 0, + "TOOL_CHOICE_MODE_AUTO": 1, + "TOOL_CHOICE_MODE_ANY": 2, + "TOOL_CHOICE_MODE_NONE": 3, + "TOOL_CHOICE_MODE_SPECIFIC": 4, + } +) + +func (x ToolChoiceMode) Enum() *ToolChoiceMode { + p := new(ToolChoiceMode) + *p = x + return p +} + +func (x ToolChoiceMode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ToolChoiceMode) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_model_v1_types_proto_enumTypes[2].Descriptor() +} + +func (ToolChoiceMode) Type() protoreflect.EnumType { + return &file_pluggableharness_model_v1_types_proto_enumTypes[2] +} + +func (x ToolChoiceMode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ToolChoiceMode.Descriptor instead. +func (ToolChoiceMode) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{2} +} + +// Capabilities is GetCapabilities' response payload: every model this +// plugin can serve, plus provider-wide declarations that apply once, not +// per model. +type Capabilities struct { + state protoimpl.MessageState `protogen:"open.v1"` + // One ModelSpec per model the plugin can serve. MUST have at least one + // entry. + Models []*ModelSpec `protobuf:"bytes,1,rep,name=models,proto3" json:"models,omitempty"` + // Prompt-expansion slash commands this provider contributes, declared + // once for the provider as a whole (not per model), per model.md §2 + // and configuration.md §5 / frontend.md §5. MAY be empty. A + // direct-invoke command is declared by a slashcommand.v1 provider + // instead (specifications/slashcommand/), never here. + SlashCommands []*v1.PromptExpansionSpec `protobuf:"bytes,2,rep,name=slash_commands,json=slashCommands,proto3" json:"slash_commands,omitempty"` + // The provider's agent.hcl config schema, returned alongside + // capabilities so the kernel knows what fields Configure expects, per + // configuration.md §4. + ConfigSchema *v11.ConfigSchema `protobuf:"bytes,3,opt,name=config_schema,json=configSchema,proto3" json:"config_schema,omitempty"` + // Which hook points (agent-loop/hook-dispatch.md) this plugin can serve + // via HookSubscriberService.DispatchHook. The kernel MUST reject an + // agent.hcl hook{} block naming a point not present here, at + // config-load time. + // + // Typed as common.v1.HookPoint, not hook.v1.HookPoint: hook/v1/events.proto + // imports model/v1/types.proto (for ModelRef/Usage on its PreModelCall/ + // PostModelResponse hook payloads), so model/v1/types.proto importing + // anything from hook.v1 for this field would be a cyclic file dependency — confirmed via + // `buf build`, which rejects it outright ("detected cyclic import"). + // HookPoint itself lives in common.v1 for exactly this reason (see + // common/v1/types.proto), already imported here for CallContext/Describe. + SupportedHookPoints []v1.HookPoint `protobuf:"varint,4,rep,packed,name=supported_hook_points,json=supportedHookPoints,proto3,enum=pluggableharness.common.v1.HookPoint" json:"supported_hook_points,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Capabilities) Reset() { + *x = Capabilities{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Capabilities) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Capabilities) ProtoMessage() {} + +func (x *Capabilities) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[0] + 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 Capabilities.ProtoReflect.Descriptor instead. +func (*Capabilities) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{0} +} + +func (x *Capabilities) GetModels() []*ModelSpec { + if x != nil { + return x.Models + } + return nil +} + +func (x *Capabilities) GetSlashCommands() []*v1.PromptExpansionSpec { + if x != nil { + return x.SlashCommands + } + return nil +} + +func (x *Capabilities) GetConfigSchema() *v11.ConfigSchema { + if x != nil { + return x.ConfigSchema + } + return nil +} + +func (x *Capabilities) GetSupportedHookPoints() []v1.HookPoint { + if x != nil { + return x.SupportedHookPoints + } + return nil +} + +// ModelSpec describes one model this provider can serve, per +// model.md §2. Every field below is MUST unless its comment says +// otherwise. +type ModelSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The vendor's exact model identifier, used to select this model in + // StreamCompletionRequest.model_id. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // The model's input token budget. + ContextWindow int64 `protobuf:"varint,2,opt,name=context_window,json=contextWindow,proto3" json:"context_window,omitempty"` + // The model's maximum output tokens per response. + MaxOutputTokens int64 `protobuf:"varint,3,opt,name=max_output_tokens,json=maxOutputTokens,proto3" json:"max_output_tokens,omitempty"` + // Whether this model can accept tool declarations and emit tool_use + // content blocks. + SupportsToolUse bool `protobuf:"varint,4,opt,name=supports_tool_use,json=supportsToolUse,proto3" json:"supports_tool_use,omitempty"` + // Whether this model can accept image content blocks. + SupportsVision bool `protobuf:"varint,5,opt,name=supports_vision,json=supportsVision,proto3" json:"supports_vision,omitempty"` + // Whether the vendor's own backend streams responses. A UX hint only + // (e.g. "don't render a live-typing cursor" when false) — the + // StreamCompletion RPC shape is always server-streaming regardless of + // this value, per model.md §1/§4. + SupportsStreaming bool `protobuf:"varint,6,opt,name=supports_streaming,json=supportsStreaming,proto3" json:"supports_streaming,omitempty"` + // Whether this model can return multiple tool_use blocks in a single + // turn. SHOULD be set accurately; a false or absent value means the + // kernel MUST serialize tool calls for this model. + SupportsParallelToolCalls *bool `protobuf:"varint,7,opt,name=supports_parallel_tool_calls,json=supportsParallelToolCalls,proto3,oneof" json:"supports_parallel_tool_calls,omitempty"` + // This model's extended-reasoning capability. MUST be present even when + // unsupported — use { supported: false } rather than omitting the + // message, so a caller never has to distinguish "unset" from "no + // thinking mode". + Thinking *ThinkingSpec `protobuf:"bytes,8,opt,name=thinking,proto3" json:"thinking,omitempty"` + // This model's prompt-caching capability. MUST be present even when + // unsupported — use { supported: false } rather than omitting the + // message. + Caching *CachingSpec `protobuf:"bytes,9,opt,name=caching,proto3" json:"caching,omitempty"` + // This model's pricing. MUST be present even for a free model (set + // Pricing.free = true). + Pricing *Pricing `protobuf:"bytes,10,opt,name=pricing,proto3" json:"pricing,omitempty"` + // Which GenerationParams.tool_choice.mode values this model accepts. + // SHOULD declare precisely which subset a vendor supports rather than + // collapsing to a bool, mirroring ThinkingSpec/CachingSpec's sum-type + // rationale above — vendors differ in which of AUTO/ANY/NONE/SPECIFIC + // they expose. Empty means this model does not support constraining + // tool choice at all (only free-form model-decides behavior); the + // kernel MUST NOT send a GenerationParams.tool_choice with a mode + // absent from this list. + SupportedToolChoiceModes []ToolChoiceMode `protobuf:"varint,11,rep,packed,name=supported_tool_choice_modes,json=supportedToolChoiceModes,proto3,enum=pluggableharness.model.v1.ToolChoiceMode" json:"supported_tool_choice_modes,omitempty"` + // Whether this model can accept document content blocks (content.v1 + // DocumentBlock, e.g. inline PDFs). Mirrors supports_vision's rule: + // the kernel MUST reject a DocumentBlock sent to a model where this is + // false, with invalid_request, rather than silently dropping it. + SupportsDocuments bool `protobuf:"varint,12,opt,name=supports_documents,json=supportsDocuments,proto3" json:"supports_documents,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModelSpec) Reset() { + *x = ModelSpec{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModelSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModelSpec) ProtoMessage() {} + +func (x *ModelSpec) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[1] + 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 ModelSpec.ProtoReflect.Descriptor instead. +func (*ModelSpec) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{1} +} + +func (x *ModelSpec) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ModelSpec) GetContextWindow() int64 { + if x != nil { + return x.ContextWindow + } + return 0 +} + +func (x *ModelSpec) GetMaxOutputTokens() int64 { + if x != nil { + return x.MaxOutputTokens + } + return 0 +} + +func (x *ModelSpec) GetSupportsToolUse() bool { + if x != nil { + return x.SupportsToolUse + } + return false +} + +func (x *ModelSpec) GetSupportsVision() bool { + if x != nil { + return x.SupportsVision + } + return false +} + +func (x *ModelSpec) GetSupportsStreaming() bool { + if x != nil { + return x.SupportsStreaming + } + return false +} + +func (x *ModelSpec) GetSupportsParallelToolCalls() bool { + if x != nil && x.SupportsParallelToolCalls != nil { + return *x.SupportsParallelToolCalls + } + return false +} + +func (x *ModelSpec) GetThinking() *ThinkingSpec { + if x != nil { + return x.Thinking + } + return nil +} + +func (x *ModelSpec) GetCaching() *CachingSpec { + if x != nil { + return x.Caching + } + return nil +} + +func (x *ModelSpec) GetPricing() *Pricing { + if x != nil { + return x.Pricing + } + return nil +} + +func (x *ModelSpec) GetSupportedToolChoiceModes() []ToolChoiceMode { + if x != nil { + return x.SupportedToolChoiceModes + } + return nil +} + +func (x *ModelSpec) GetSupportsDocuments() bool { + if x != nil { + return x.SupportsDocuments + } + return false +} + +// ThinkingBudgetRange bounds the token budget a caller may request when +// ThinkingMode is THINKING_MODE_CONTINUOUS_BUDGET. +type ThinkingBudgetRange struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The smallest thinking-token budget this model accepts. + Min int64 `protobuf:"varint,1,opt,name=min,proto3" json:"min,omitempty"` + // The largest thinking-token budget this model accepts. + Max int64 `protobuf:"varint,2,opt,name=max,proto3" json:"max,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ThinkingBudgetRange) Reset() { + *x = ThinkingBudgetRange{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ThinkingBudgetRange) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ThinkingBudgetRange) ProtoMessage() {} + +func (x *ThinkingBudgetRange) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[2] + 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 ThinkingBudgetRange.ProtoReflect.Descriptor instead. +func (*ThinkingBudgetRange) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{2} +} + +func (x *ThinkingBudgetRange) GetMin() int64 { + if x != nil { + return x.Min + } + return 0 +} + +func (x *ThinkingBudgetRange) GetMax() int64 { + if x != nil { + return x.Max + } + return 0 +} + +// ThinkingSpec describes one model's extended-reasoning capability, per +// model.md §2. +type ThinkingSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Whether this model has any extended-reasoning capability at all. + Supported bool `protobuf:"varint,1,opt,name=supported,proto3" json:"supported,omitempty"` + // Which reasoning-control shape this model uses. MUST be + // THINKING_MODE_NONE when supported == false. + Mode ThinkingMode `protobuf:"varint,2,opt,name=mode,proto3,enum=pluggableharness.model.v1.ThinkingMode" json:"mode,omitempty"` + // The selectable effort levels, e.g. ["low","medium","high","xhigh", + // "max"]. MUST be non-empty when mode == THINKING_MODE_DISCRETE_EFFORT; + // meaningless otherwise. + EffortLevels []string `protobuf:"bytes,3,rep,name=effort_levels,json=effortLevels,proto3" json:"effort_levels,omitempty"` + // The selectable token-budget range. MUST be present when mode == + // THINKING_MODE_CONTINUOUS_BUDGET; meaningless otherwise. + BudgetRange *ThinkingBudgetRange `protobuf:"bytes,4,opt,name=budget_range,json=budgetRange,proto3,oneof" json:"budget_range,omitempty"` + // Whether reasoning can be turned off once enabled. MUST be set + // accurately — some vendors' reasoning cannot be disabled (e.g. a + // researched Grok model defaults reasoning on with no off switch). + CanDisable bool `protobuf:"varint,5,opt,name=can_disable,json=canDisable,proto3" json:"can_disable,omitempty"` + // The effort level (discrete_effort) or budget-token value + // (continuous_budget), as a string, the vendor applies when a request + // omits thinking config entirely. MUST be set when mode != + // THINKING_MODE_NONE — makes the vendor's actual default behavior + // visible/auditable via GetCapabilities rather than hidden in adapter + // code, so a kernel wanting deterministic behavior can always send an + // explicit override. + Default *string `protobuf:"bytes,6,opt,name=default,proto3,oneof" json:"default,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ThinkingSpec) Reset() { + *x = ThinkingSpec{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ThinkingSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ThinkingSpec) ProtoMessage() {} + +func (x *ThinkingSpec) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[3] + 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 ThinkingSpec.ProtoReflect.Descriptor instead. +func (*ThinkingSpec) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{3} +} + +func (x *ThinkingSpec) GetSupported() bool { + if x != nil { + return x.Supported + } + return false +} + +func (x *ThinkingSpec) GetMode() ThinkingMode { + if x != nil { + return x.Mode + } + return ThinkingMode_THINKING_MODE_UNSPECIFIED +} + +func (x *ThinkingSpec) GetEffortLevels() []string { + if x != nil { + return x.EffortLevels + } + return nil +} + +func (x *ThinkingSpec) GetBudgetRange() *ThinkingBudgetRange { + if x != nil { + return x.BudgetRange + } + return nil +} + +func (x *ThinkingSpec) GetCanDisable() bool { + if x != nil { + return x.CanDisable + } + return false +} + +func (x *ThinkingSpec) GetDefault() string { + if x != nil && x.Default != nil { + return *x.Default + } + return "" +} + +// CachingSpec describes one model's prompt-caching capability, per +// model.md §2. +type CachingSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Whether this model has any prompt-caching capability at all. + Supported bool `protobuf:"varint,1,opt,name=supported,proto3" json:"supported,omitempty"` + // Which caching mechanic this model uses. MUST be CACHING_MODE_NONE + // when supported == false. + Mode CachingMode `protobuf:"varint,2,opt,name=mode,proto3,enum=pluggableharness.model.v1.CachingMode" json:"mode,omitempty"` + // Whether this provider runs its own cache-keepalive loop (e.g. a + // background goroutine re-pinging before a cache TTL expires, so a long + // tool-execution gap doesn't let the cache go cold). MUST be set, + // default false. Cache TTL mechanics are vendor-specific, so per + // operator decision this is a provider-owned behavior the kernel never + // drives — this field only tells the kernel/operator whether a given + // provider implements the optimization (model.md §2). + KeepaliveSupported bool `protobuf:"varint,3,opt,name=keepalive_supported,json=keepaliveSupported,proto3" json:"keepalive_supported,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CachingSpec) Reset() { + *x = CachingSpec{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CachingSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CachingSpec) ProtoMessage() {} + +func (x *CachingSpec) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[4] + 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 CachingSpec.ProtoReflect.Descriptor instead. +func (*CachingSpec) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{4} +} + +func (x *CachingSpec) GetSupported() bool { + if x != nil { + return x.Supported + } + return false +} + +func (x *CachingSpec) GetMode() CachingMode { + if x != nil { + return x.Mode + } + return CachingMode_CACHING_MODE_UNSPECIFIED +} + +func (x *CachingSpec) GetKeepaliveSupported() bool { + if x != nil { + return x.KeepaliveSupported + } + return false +} + +// PricingTier is one time-bounded, input-size-bounded rate within a +// model's Pricing, per model.md §2. Exactly one tier MUST match at any +// given (timestamp, input_token_count) pair — timestamp resolved against +// effective_from/effective_until (an omitted bound unbounded on that +// side) AND input_token_count resolved against input_tokens_from/ +// input_tokens_until (likewise unbounded when omitted) simultaneously; +// the kernel MUST reject a Pricing value at capability-load time if its +// tiers overlap or leave a gap across either dimension, exactly as it +// already does for the time dimension alone. +type PricingTier struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The moment this tier becomes active. Omitted means "since this plugin + // version was published". Refines model.md §2's "ISO 8601 + // date/timestamp" into the native well-known type. + EffectiveFrom *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=effective_from,json=effectiveFrom,proto3,oneof" json:"effective_from,omitempty"` + // The moment this tier stops being active. Omitted means "still + // current" — an omitted effective_until marks the currently active + // tier. Refines model.md §2's "ISO 8601" into the native well-known + // type. + EffectiveUntil *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=effective_until,json=effectiveUntil,proto3,oneof" json:"effective_until,omitempty"` + // Cost per million input tokens, realtime rate. + InputPerMtok float64 `protobuf:"fixed64,3,opt,name=input_per_mtok,json=inputPerMtok,proto3" json:"input_per_mtok,omitempty"` + // Cost per million output tokens, realtime rate. + OutputPerMtok float64 `protobuf:"fixed64,4,opt,name=output_per_mtok,json=outputPerMtok,proto3" json:"output_per_mtok,omitempty"` + // Cost per million cache-write tokens. MUST be present iff + // CachingSpec.supported. + CacheWritePerMtok *float64 `protobuf:"fixed64,5,opt,name=cache_write_per_mtok,json=cacheWritePerMtok,proto3,oneof" json:"cache_write_per_mtok,omitempty"` + // Cost per million cache-read tokens, typically far cheaper than + // input_per_mtok — the entire point of caching. MUST be present iff + // CachingSpec.supported. + CacheReadPerMtok *float64 `protobuf:"fixed64,6,opt,name=cache_read_per_mtok,json=cacheReadPerMtok,proto3,oneof" json:"cache_read_per_mtok,omitempty"` + // A vendor's discounted batch/async input rate, where one exists (e.g. + // a researched Gemini batch tier). MAY be present. + BatchInputPerMtok *float64 `protobuf:"fixed64,7,opt,name=batch_input_per_mtok,json=batchInputPerMtok,proto3,oneof" json:"batch_input_per_mtok,omitempty"` + // A vendor's discounted batch/async output rate, paired with + // batch_input_per_mtok. MAY be present. + BatchOutputPerMtok *float64 `protobuf:"fixed64,8,opt,name=batch_output_per_mtok,json=batchOutputPerMtok,proto3,oneof" json:"batch_output_per_mtok,omitempty"` + // The smallest accumulated-input-token count this tier applies to, + // inclusive. Omitted means unbounded below (matches any input size down + // to zero). Real vendors price by input size as well as by time — e.g. + // a distinct, higher rate once a request's input exceeds 200k tokens — + // and this field lets a tier declare that dimension alongside the + // existing effective_from/effective_until time bounds. Refines + // model/data-types.md#pricing's tier-matching rule into two dimensions. + InputTokensFrom *int64 `protobuf:"varint,9,opt,name=input_tokens_from,json=inputTokensFrom,proto3,oneof" json:"input_tokens_from,omitempty"` + // The input-token count this tier stops applying to, exclusive. + // Omitted means unbounded above. Half-open with input_tokens_from, + // matching effective_from/effective_until's half-open convention. + InputTokensUntil *int64 `protobuf:"varint,10,opt,name=input_tokens_until,json=inputTokensUntil,proto3,oneof" json:"input_tokens_until,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PricingTier) Reset() { + *x = PricingTier{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PricingTier) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PricingTier) ProtoMessage() {} + +func (x *PricingTier) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_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 PricingTier.ProtoReflect.Descriptor instead. +func (*PricingTier) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{5} +} + +func (x *PricingTier) GetEffectiveFrom() *timestamppb.Timestamp { + if x != nil { + return x.EffectiveFrom + } + return nil +} + +func (x *PricingTier) GetEffectiveUntil() *timestamppb.Timestamp { + if x != nil { + return x.EffectiveUntil + } + return nil +} + +func (x *PricingTier) GetInputPerMtok() float64 { + if x != nil { + return x.InputPerMtok + } + return 0 +} + +func (x *PricingTier) GetOutputPerMtok() float64 { + if x != nil { + return x.OutputPerMtok + } + return 0 +} + +func (x *PricingTier) GetCacheWritePerMtok() float64 { + if x != nil && x.CacheWritePerMtok != nil { + return *x.CacheWritePerMtok + } + return 0 +} + +func (x *PricingTier) GetCacheReadPerMtok() float64 { + if x != nil && x.CacheReadPerMtok != nil { + return *x.CacheReadPerMtok + } + return 0 +} + +func (x *PricingTier) GetBatchInputPerMtok() float64 { + if x != nil && x.BatchInputPerMtok != nil { + return *x.BatchInputPerMtok + } + return 0 +} + +func (x *PricingTier) GetBatchOutputPerMtok() float64 { + if x != nil && x.BatchOutputPerMtok != nil { + return *x.BatchOutputPerMtok + } + return 0 +} + +func (x *PricingTier) GetInputTokensFrom() int64 { + if x != nil && x.InputTokensFrom != nil { + return *x.InputTokensFrom + } + return 0 +} + +func (x *PricingTier) GetInputTokensUntil() int64 { + if x != nil && x.InputTokensUntil != nil { + return *x.InputTokensUntil + } + return 0 +} + +// Pricing describes one model's cost structure, per model.md §2. MUST +// be present on every ModelSpec, even a free one. +type Pricing struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The pricing currency. MUST be "USD" for v1; reserved for future + // multi-currency support, not acted on by the kernel yet. + Currency string `protobuf:"bytes,1,opt,name=currency,proto3" json:"currency,omitempty"` + // True for a local/free-to-run model (e.g. an Ollama-served model). + // When true, tiers MAY be omitted entirely. + Free bool `protobuf:"varint,2,opt,name=free,proto3" json:"free,omitempty"` + // This model's rate tiers, ordered or not — resolution is by + // effective_from/effective_until, not array position. MUST have at + // least one entry unless free == true. Exactly one tier MUST match any + // given timestamp; the kernel MUST reject overlapping or gapped tiers + // at capability-load time. + Tiers []*PricingTier `protobuf:"bytes,3,rep,name=tiers,proto3" json:"tiers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Pricing) Reset() { + *x = Pricing{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Pricing) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Pricing) ProtoMessage() {} + +func (x *Pricing) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_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 Pricing.ProtoReflect.Descriptor instead. +func (*Pricing) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{6} +} + +func (x *Pricing) GetCurrency() string { + if x != nil { + return x.Currency + } + return "" +} + +func (x *Pricing) GetFree() bool { + if x != nil { + return x.Free + } + return false +} + +func (x *Pricing) GetTiers() []*PricingTier { + if x != nil { + return x.Tiers + } + return nil +} + +// CacheBreakpoint marks one position in a StreamCompletionRequest where +// the kernel wants the adapter to insert a vendor-native cache-control +// marker, per StreamCompletionRequest.cache_breakpoints above. +type CacheBreakpoint struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The position this breakpoint marks. Exactly one variant is set. + // + // Types that are valid to be assigned to Position: + // + // *CacheBreakpoint_AfterAssembledContext_ + // *CacheBreakpoint_AfterTools_ + // *CacheBreakpoint_AfterMessageIndex + Position isCacheBreakpoint_Position `protobuf_oneof:"position"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CacheBreakpoint) Reset() { + *x = CacheBreakpoint{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CacheBreakpoint) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CacheBreakpoint) ProtoMessage() {} + +func (x *CacheBreakpoint) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[7] + 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 CacheBreakpoint.ProtoReflect.Descriptor instead. +func (*CacheBreakpoint) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{7} +} + +func (x *CacheBreakpoint) GetPosition() isCacheBreakpoint_Position { + if x != nil { + return x.Position + } + return nil +} + +func (x *CacheBreakpoint) GetAfterAssembledContext() *CacheBreakpoint_AfterAssembledContext { + if x != nil { + if x, ok := x.Position.(*CacheBreakpoint_AfterAssembledContext_); ok { + return x.AfterAssembledContext + } + } + return nil +} + +func (x *CacheBreakpoint) GetAfterTools() *CacheBreakpoint_AfterTools { + if x != nil { + if x, ok := x.Position.(*CacheBreakpoint_AfterTools_); ok { + return x.AfterTools + } + } + return nil +} + +func (x *CacheBreakpoint) GetAfterMessageIndex() int64 { + if x != nil { + if x, ok := x.Position.(*CacheBreakpoint_AfterMessageIndex); ok { + return x.AfterMessageIndex + } + } + return 0 +} + +type isCacheBreakpoint_Position interface { + isCacheBreakpoint_Position() +} + +type CacheBreakpoint_AfterAssembledContext_ struct { + // Immediately after the assembled_context chain — the kernel's most + // common choice, since assembled_context is usually the longest + // stable prefix (context/data-types.md#ordering--chaining orders + // STABILITY_STATIC sections before STABILITY_DYNAMIC ones). + AfterAssembledContext *CacheBreakpoint_AfterAssembledContext `protobuf:"bytes,1,opt,name=after_assembled_context,json=afterAssembledContext,proto3,oneof"` +} + +type CacheBreakpoint_AfterTools_ struct { + // Immediately after the tools declaration list. + AfterTools *CacheBreakpoint_AfterTools `protobuf:"bytes,2,opt,name=after_tools,json=afterTools,proto3,oneof"` +} + +type CacheBreakpoint_AfterMessageIndex struct { + // Immediately after the message at this zero-based index within + // `messages`. + AfterMessageIndex int64 `protobuf:"varint,3,opt,name=after_message_index,json=afterMessageIndex,proto3,oneof"` +} + +func (*CacheBreakpoint_AfterAssembledContext_) isCacheBreakpoint_Position() {} + +func (*CacheBreakpoint_AfterTools_) isCacheBreakpoint_Position() {} + +func (*CacheBreakpoint_AfterMessageIndex) isCacheBreakpoint_Position() {} + +// ToolDeclaration is one tool the model may call on this turn, per +// model.md §6. Each model-provider adapter translates this into its +// vendor's own tool-definition wire format. +type ToolDeclaration struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The tool's name, as the model must reference it in a ToolUseBlock. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Human-readable description shown to the model to help it decide + // whether and how to call this tool. + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + // The tool's input shape, in the restricted JSON-Schema subset shared + // across categories (model.md §6, pluggableharness.schema.v1.Schema). + InputSchema *v12.Schema `protobuf:"bytes,3,opt,name=input_schema,json=inputSchema,proto3" json:"input_schema,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ToolDeclaration) Reset() { + *x = ToolDeclaration{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ToolDeclaration) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolDeclaration) ProtoMessage() {} + +func (x *ToolDeclaration) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[8] + 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 ToolDeclaration.ProtoReflect.Descriptor instead. +func (*ToolDeclaration) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{8} +} + +func (x *ToolDeclaration) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ToolDeclaration) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ToolDeclaration) GetInputSchema() *v12.Schema { + if x != nil { + return x.InputSchema + } + return nil +} + +// GenerationParams carries per-request overrides of otherwise +// model-default generation behavior. +type GenerationParams struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Selects one of ThinkingSpec.effort_levels. Meaningful only when the + // target model's ThinkingSpec.mode == THINKING_MODE_DISCRETE_EFFORT. + ThinkingEffort *string `protobuf:"bytes,1,opt,name=thinking_effort,json=thinkingEffort,proto3,oneof" json:"thinking_effort,omitempty"` + // Selects a token budget within ThinkingSpec.budget_range. Meaningful + // only when the target model's ThinkingSpec.mode == + // THINKING_MODE_CONTINUOUS_BUDGET. + ThinkingBudgetTokens *int64 `protobuf:"varint,2,opt,name=thinking_budget_tokens,json=thinkingBudgetTokens,proto3,oneof" json:"thinking_budget_tokens,omitempty"` + // Per-request override of ModelSpec.max_output_tokens. Omitted means + // use the model's default. + MaxOutputTokens *int64 `protobuf:"varint,3,opt,name=max_output_tokens,json=maxOutputTokens,proto3,oneof" json:"max_output_tokens,omitempty"` + // Sampling temperature. Omitted means use the model's default. Range + // and exact semantics are vendor-specific; the kernel does not clamp + // or reinterpret this value, it is passed through to the adapter as + // given. + Temperature *float64 `protobuf:"fixed64,4,opt,name=temperature,proto3,oneof" json:"temperature,omitempty"` + // Sequences that, if generated, MUST cause the model to stop before + // producing them. Omitted/empty means no caller-supplied stop + // sequences. When a vendor honors one of these, the plugin MUST report + // it back via StreamEvent.Stop.matched_stop_sequence with StopReason + // STOP_REASON_STOP_SEQUENCE. + StopSequences []string `protobuf:"bytes,5,rep,name=stop_sequences,json=stopSequences,proto3" json:"stop_sequences,omitempty"` + // Constrains whether/how the model must use a tool this turn. Omitted + // means the model decides freely (equivalent to + // TOOL_CHOICE_MODE_AUTO). Meaningful only when the target model's + // ModelSpec.supported_tool_choice_modes is non-empty; the kernel MUST + // NOT send a mode absent from that list, mirroring + // thinking_effort/thinking_budget_tokens' ThinkingSpec-validation rule + // above — an unsupported mode is a kernel-level reject-or-fallback, not + // something forwarded to the vendor. + ToolChoice *ToolChoice `protobuf:"bytes,6,opt,name=tool_choice,json=toolChoice,proto3,oneof" json:"tool_choice,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GenerationParams) Reset() { + *x = GenerationParams{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GenerationParams) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GenerationParams) ProtoMessage() {} + +func (x *GenerationParams) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[9] + 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 GenerationParams.ProtoReflect.Descriptor instead. +func (*GenerationParams) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{9} +} + +func (x *GenerationParams) GetThinkingEffort() string { + if x != nil && x.ThinkingEffort != nil { + return *x.ThinkingEffort + } + return "" +} + +func (x *GenerationParams) GetThinkingBudgetTokens() int64 { + if x != nil && x.ThinkingBudgetTokens != nil { + return *x.ThinkingBudgetTokens + } + return 0 +} + +func (x *GenerationParams) GetMaxOutputTokens() int64 { + if x != nil && x.MaxOutputTokens != nil { + return *x.MaxOutputTokens + } + return 0 +} + +func (x *GenerationParams) GetTemperature() float64 { + if x != nil && x.Temperature != nil { + return *x.Temperature + } + return 0 +} + +func (x *GenerationParams) GetStopSequences() []string { + if x != nil { + return x.StopSequences + } + return nil +} + +func (x *GenerationParams) GetToolChoice() *ToolChoice { + if x != nil { + return x.ToolChoice + } + return nil +} + +// ToolChoice carries one request's tool-invocation constraint, per +// GenerationParams.tool_choice above. +type ToolChoice struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Which constraint shape applies. MUST be set. + Mode ToolChoiceMode `protobuf:"varint,1,opt,name=mode,proto3,enum=pluggableharness.model.v1.ToolChoiceMode" json:"mode,omitempty"` + // The tool the model MUST call. MUST be set, and MUST name a tool + // present in StreamCompletionRequest.tools, when mode == + // TOOL_CHOICE_MODE_SPECIFIC; meaningless and MUST be omitted for every + // other mode. + ToolName *string `protobuf:"bytes,2,opt,name=tool_name,json=toolName,proto3,oneof" json:"tool_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ToolChoice) Reset() { + *x = ToolChoice{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ToolChoice) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolChoice) ProtoMessage() {} + +func (x *ToolChoice) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[10] + 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 ToolChoice.ProtoReflect.Descriptor instead. +func (*ToolChoice) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{10} +} + +func (x *ToolChoice) GetMode() ToolChoiceMode { + if x != nil { + return x.Mode + } + return ToolChoiceMode_TOOL_CHOICE_MODE_UNSPECIFIED +} + +func (x *ToolChoice) GetToolName() string { + if x != nil && x.ToolName != nil { + return *x.ToolName + } + return "" +} + +// Usage carries token accounting for one completion, per model.md §4.1. +// The kernel computes and persists cost_usd from these counts plus the +// matching PricingTier — the plugin never computes cost itself. Promoted +// to a top-level message, rather than nested under StreamEvent, because +// it is reused outside the stream (event payloads, frontend usage +// updates — forthcoming). +type Usage struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Input tokens consumed by this completion. + InputTokens int64 `protobuf:"varint,1,opt,name=input_tokens,json=inputTokens,proto3" json:"input_tokens,omitempty"` + // Output tokens produced by this completion. + OutputTokens int64 `protobuf:"varint,2,opt,name=output_tokens,json=outputTokens,proto3" json:"output_tokens,omitempty"` + // Tokens read from cache, if the model supports caching. Never also + // counted in input_tokens. + CacheReadTokens *int64 `protobuf:"varint,3,opt,name=cache_read_tokens,json=cacheReadTokens,proto3,oneof" json:"cache_read_tokens,omitempty"` + // Tokens written to cache, if the model supports caching. Never also + // counted in input_tokens. + CacheWriteTokens *int64 `protobuf:"varint,4,opt,name=cache_write_tokens,json=cacheWriteTokens,proto3,oneof" json:"cache_write_tokens,omitempty"` + // Thinking/reasoning tokens, when the vendor reports them as a + // distinct count (ThinkingSpec.supported models only). Never also + // counted in output_tokens — a vendor that folds reasoning tokens into + // its reported output_tokens has no separate figure to report here, so + // this stays unset in that case rather than being derived/subtracted. + // Billed at the output rate (PricingTier.output_per_mtok) unless a + // future Pricing revision declares a distinct reasoning rate — there is + // none as of this revision. + ReasoningTokens *int64 `protobuf:"varint,5,opt,name=reasoning_tokens,json=reasoningTokens,proto3,oneof" json:"reasoning_tokens,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Usage) Reset() { + *x = Usage{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Usage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Usage) ProtoMessage() {} + +func (x *Usage) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[11] + 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 Usage.ProtoReflect.Descriptor instead. +func (*Usage) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{11} +} + +func (x *Usage) GetInputTokens() int64 { + if x != nil { + return x.InputTokens + } + return 0 +} + +func (x *Usage) GetOutputTokens() int64 { + if x != nil { + return x.OutputTokens + } + return 0 +} + +func (x *Usage) GetCacheReadTokens() int64 { + if x != nil && x.CacheReadTokens != nil { + return *x.CacheReadTokens + } + return 0 +} + +func (x *Usage) GetCacheWriteTokens() int64 { + if x != nil && x.CacheWriteTokens != nil { + return *x.CacheWriteTokens + } + return 0 +} + +func (x *Usage) GetReasoningTokens() int64 { + if x != nil && x.ReasoningTokens != nil { + return *x.ReasoningTokens + } + return 0 +} + +// ModelTarget describes the model a context or memory contribution is +// being assembled for, derived from that model's ModelSpec +// (model.md §2). Carried on context.md's ContextRequest and memory.md's +// RecallRequest so a provider can tailor its contribution (and compute +// tokens against the right budget) for the model that will actually +// consume it. +type ModelTarget struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The target model's ModelSpec.id (model.md §2) — the vendor's exact + // model identifier. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // The target model's total input token budget (model.md §2 + // ModelSpec.context_window). + ContextWindow int64 `protobuf:"varint,2,opt,name=context_window,json=contextWindow,proto3" json:"context_window,omitempty"` + // The usable portion of context_window after the kernel reserves space + // for expected output, tool schemas, and other fixed overhead for this + // turn — the number a context/memory provider should actually budget + // its contribution against, rather than the raw context_window. + EffectiveCeiling int64 `protobuf:"varint,3,opt,name=effective_ceiling,json=effectiveCeiling,proto3" json:"effective_ceiling,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModelTarget) Reset() { + *x = ModelTarget{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModelTarget) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModelTarget) ProtoMessage() {} + +func (x *ModelTarget) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[12] + 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 ModelTarget.ProtoReflect.Descriptor instead. +func (*ModelTarget) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{12} +} + +func (x *ModelTarget) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ModelTarget) GetContextWindow() int64 { + if x != nil { + return x.ContextWindow + } + return 0 +} + +func (x *ModelTarget) GetEffectiveCeiling() int64 { + if x != nil { + return x.EffectiveCeiling + } + return 0 +} + +// ModelRef narrowly selects a model for token-counting purposes +// (kernel-callbacks.md §2 CountTokensRequest.model_ref). Optional on the +// request it's embedded in — omitted means "use the kernel's fallback +// heuristic," never "count against an unspecified model." +type ModelRef struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The declared name of the model provider plugin (common.v1.ProducerRef + // or ProviderRef's name field), e.g. "anthropic". + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + // The model's ModelSpec.id within that provider. + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModelRef) Reset() { + *x = ModelRef{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModelRef) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModelRef) ProtoMessage() {} + +func (x *ModelRef) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[13] + 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 ModelRef.ProtoReflect.Descriptor instead. +func (*ModelRef) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{13} +} + +func (x *ModelRef) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *ModelRef) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// AfterAssembledContext is an empty marker message: its presence as the +// set oneof variant is the entire signal, no further data needed. +type CacheBreakpoint_AfterAssembledContext struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CacheBreakpoint_AfterAssembledContext) Reset() { + *x = CacheBreakpoint_AfterAssembledContext{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CacheBreakpoint_AfterAssembledContext) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CacheBreakpoint_AfterAssembledContext) ProtoMessage() {} + +func (x *CacheBreakpoint_AfterAssembledContext) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[14] + 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 CacheBreakpoint_AfterAssembledContext.ProtoReflect.Descriptor instead. +func (*CacheBreakpoint_AfterAssembledContext) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{7, 0} +} + +// AfterTools is an empty marker message: its presence as the set oneof +// variant is the entire signal, no further data needed. +type CacheBreakpoint_AfterTools struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CacheBreakpoint_AfterTools) Reset() { + *x = CacheBreakpoint_AfterTools{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CacheBreakpoint_AfterTools) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CacheBreakpoint_AfterTools) ProtoMessage() {} + +func (x *CacheBreakpoint_AfterTools) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_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 CacheBreakpoint_AfterTools.ProtoReflect.Descriptor instead. +func (*CacheBreakpoint_AfterTools) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{7, 1} +} + +var File_pluggableharness_model_v1_types_proto protoreflect.FileDescriptor + +const file_pluggableharness_model_v1_types_proto_rawDesc = "" + + "\n" + + "%pluggableharness/model/v1/types.proto\x12\x19pluggableharness.model.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/config/v1/types.proto\x1a&pluggableharness/schema/v1/types.proto\"\xce\x02\n" + + "\fCapabilities\x12<\n" + + "\x06models\x18\x01 \x03(\v2$.pluggableharness.model.v1.ModelSpecR\x06models\x12V\n" + + "\x0eslash_commands\x18\x02 \x03(\v2/.pluggableharness.common.v1.PromptExpansionSpecR\rslashCommands\x12M\n" + + "\rconfig_schema\x18\x03 \x01(\v2(.pluggableharness.config.v1.ConfigSchemaR\fconfigSchema\x12Y\n" + + "\x15supported_hook_points\x18\x04 \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPoints\"\xb7\x05\n" + + "\tModelSpec\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12%\n" + + "\x0econtext_window\x18\x02 \x01(\x03R\rcontextWindow\x12*\n" + + "\x11max_output_tokens\x18\x03 \x01(\x03R\x0fmaxOutputTokens\x12*\n" + + "\x11supports_tool_use\x18\x04 \x01(\bR\x0fsupportsToolUse\x12'\n" + + "\x0fsupports_vision\x18\x05 \x01(\bR\x0esupportsVision\x12-\n" + + "\x12supports_streaming\x18\x06 \x01(\bR\x11supportsStreaming\x12D\n" + + "\x1csupports_parallel_tool_calls\x18\a \x01(\bH\x00R\x19supportsParallelToolCalls\x88\x01\x01\x12C\n" + + "\bthinking\x18\b \x01(\v2'.pluggableharness.model.v1.ThinkingSpecR\bthinking\x12@\n" + + "\acaching\x18\t \x01(\v2&.pluggableharness.model.v1.CachingSpecR\acaching\x12<\n" + + "\apricing\x18\n" + + " \x01(\v2\".pluggableharness.model.v1.PricingR\apricing\x12h\n" + + "\x1bsupported_tool_choice_modes\x18\v \x03(\x0e2).pluggableharness.model.v1.ToolChoiceModeR\x18supportedToolChoiceModes\x12-\n" + + "\x12supports_documents\x18\f \x01(\bR\x11supportsDocumentsB\x1f\n" + + "\x1d_supports_parallel_tool_calls\"9\n" + + "\x13ThinkingBudgetRange\x12\x10\n" + + "\x03min\x18\x01 \x01(\x03R\x03min\x12\x10\n" + + "\x03max\x18\x02 \x01(\x03R\x03max\"\xc3\x02\n" + + "\fThinkingSpec\x12\x1c\n" + + "\tsupported\x18\x01 \x01(\bR\tsupported\x12;\n" + + "\x04mode\x18\x02 \x01(\x0e2'.pluggableharness.model.v1.ThinkingModeR\x04mode\x12#\n" + + "\reffort_levels\x18\x03 \x03(\tR\feffortLevels\x12V\n" + + "\fbudget_range\x18\x04 \x01(\v2..pluggableharness.model.v1.ThinkingBudgetRangeH\x00R\vbudgetRange\x88\x01\x01\x12\x1f\n" + + "\vcan_disable\x18\x05 \x01(\bR\n" + + "canDisable\x12\x1d\n" + + "\adefault\x18\x06 \x01(\tH\x01R\adefault\x88\x01\x01B\x0f\n" + + "\r_budget_rangeB\n" + + "\n" + + "\b_default\"\x98\x01\n" + + "\vCachingSpec\x12\x1c\n" + + "\tsupported\x18\x01 \x01(\bR\tsupported\x12:\n" + + "\x04mode\x18\x02 \x01(\x0e2&.pluggableharness.model.v1.CachingModeR\x04mode\x12/\n" + + "\x13keepalive_supported\x18\x03 \x01(\bR\x12keepaliveSupported\"\xe1\x05\n" + + "\vPricingTier\x12F\n" + + "\x0eeffective_from\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampH\x00R\reffectiveFrom\x88\x01\x01\x12H\n" + + "\x0feffective_until\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampH\x01R\x0eeffectiveUntil\x88\x01\x01\x12$\n" + + "\x0einput_per_mtok\x18\x03 \x01(\x01R\finputPerMtok\x12&\n" + + "\x0foutput_per_mtok\x18\x04 \x01(\x01R\routputPerMtok\x124\n" + + "\x14cache_write_per_mtok\x18\x05 \x01(\x01H\x02R\x11cacheWritePerMtok\x88\x01\x01\x122\n" + + "\x13cache_read_per_mtok\x18\x06 \x01(\x01H\x03R\x10cacheReadPerMtok\x88\x01\x01\x124\n" + + "\x14batch_input_per_mtok\x18\a \x01(\x01H\x04R\x11batchInputPerMtok\x88\x01\x01\x126\n" + + "\x15batch_output_per_mtok\x18\b \x01(\x01H\x05R\x12batchOutputPerMtok\x88\x01\x01\x12/\n" + + "\x11input_tokens_from\x18\t \x01(\x03H\x06R\x0finputTokensFrom\x88\x01\x01\x121\n" + + "\x12input_tokens_until\x18\n" + + " \x01(\x03H\aR\x10inputTokensUntil\x88\x01\x01B\x11\n" + + "\x0f_effective_fromB\x12\n" + + "\x10_effective_untilB\x17\n" + + "\x15_cache_write_per_mtokB\x16\n" + + "\x14_cache_read_per_mtokB\x17\n" + + "\x15_batch_input_per_mtokB\x18\n" + + "\x16_batch_output_per_mtokB\x14\n" + + "\x12_input_tokens_fromB\x15\n" + + "\x13_input_tokens_until\"w\n" + + "\aPricing\x12\x1a\n" + + "\bcurrency\x18\x01 \x01(\tR\bcurrency\x12\x12\n" + + "\x04free\x18\x02 \x01(\bR\x04free\x12<\n" + + "\x05tiers\x18\x03 \x03(\v2&.pluggableharness.model.v1.PricingTierR\x05tiers\"\xcc\x02\n" + + "\x0fCacheBreakpoint\x12z\n" + + "\x17after_assembled_context\x18\x01 \x01(\v2@.pluggableharness.model.v1.CacheBreakpoint.AfterAssembledContextH\x00R\x15afterAssembledContext\x12X\n" + + "\vafter_tools\x18\x02 \x01(\v25.pluggableharness.model.v1.CacheBreakpoint.AfterToolsH\x00R\n" + + "afterTools\x120\n" + + "\x13after_message_index\x18\x03 \x01(\x03H\x00R\x11afterMessageIndex\x1a\x17\n" + + "\x15AfterAssembledContext\x1a\f\n" + + "\n" + + "AfterToolsB\n" + + "\n" + + "\bposition\"\x8e\x01\n" + + "\x0fToolDeclaration\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x02 \x01(\tR\vdescription\x12E\n" + + "\finput_schema\x18\x03 \x01(\v2\".pluggableharness.schema.v1.SchemaR\vinputSchema\"\xac\x03\n" + + "\x10GenerationParams\x12,\n" + + "\x0fthinking_effort\x18\x01 \x01(\tH\x00R\x0ethinkingEffort\x88\x01\x01\x129\n" + + "\x16thinking_budget_tokens\x18\x02 \x01(\x03H\x01R\x14thinkingBudgetTokens\x88\x01\x01\x12/\n" + + "\x11max_output_tokens\x18\x03 \x01(\x03H\x02R\x0fmaxOutputTokens\x88\x01\x01\x12%\n" + + "\vtemperature\x18\x04 \x01(\x01H\x03R\vtemperature\x88\x01\x01\x12%\n" + + "\x0estop_sequences\x18\x05 \x03(\tR\rstopSequences\x12K\n" + + "\vtool_choice\x18\x06 \x01(\v2%.pluggableharness.model.v1.ToolChoiceH\x04R\n" + + "toolChoice\x88\x01\x01B\x12\n" + + "\x10_thinking_effortB\x19\n" + + "\x17_thinking_budget_tokensB\x14\n" + + "\x12_max_output_tokensB\x0e\n" + + "\f_temperatureB\x0e\n" + + "\f_tool_choice\"{\n" + + "\n" + + "ToolChoice\x12=\n" + + "\x04mode\x18\x01 \x01(\x0e2).pluggableharness.model.v1.ToolChoiceModeR\x04mode\x12 \n" + + "\ttool_name\x18\x02 \x01(\tH\x00R\btoolName\x88\x01\x01B\f\n" + + "\n" + + "_tool_name\"\xa5\x02\n" + + "\x05Usage\x12!\n" + + "\finput_tokens\x18\x01 \x01(\x03R\vinputTokens\x12#\n" + + "\routput_tokens\x18\x02 \x01(\x03R\foutputTokens\x12/\n" + + "\x11cache_read_tokens\x18\x03 \x01(\x03H\x00R\x0fcacheReadTokens\x88\x01\x01\x121\n" + + "\x12cache_write_tokens\x18\x04 \x01(\x03H\x01R\x10cacheWriteTokens\x88\x01\x01\x12.\n" + + "\x10reasoning_tokens\x18\x05 \x01(\x03H\x02R\x0freasoningTokens\x88\x01\x01B\x14\n" + + "\x12_cache_read_tokensB\x15\n" + + "\x13_cache_write_tokensB\x13\n" + + "\x11_reasoning_tokens\"q\n" + + "\vModelTarget\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12%\n" + + "\x0econtext_window\x18\x02 \x01(\x03R\rcontextWindow\x12+\n" + + "\x11effective_ceiling\x18\x03 \x01(\x03R\x10effectiveCeiling\"6\n" + + "\bModelRef\x12\x1a\n" + + "\bprovider\x18\x01 \x01(\tR\bprovider\x12\x0e\n" + + "\x02id\x18\x02 \x01(\tR\x02id*\xb3\x01\n" + + "\fThinkingMode\x12\x1d\n" + + "\x19THINKING_MODE_UNSPECIFIED\x10\x00\x12\x16\n" + + "\x12THINKING_MODE_NONE\x10\x01\x12$\n" + + " THINKING_MODE_ALWAYS_ON_ADAPTIVE\x10\x02\x12!\n" + + "\x1dTHINKING_MODE_DISCRETE_EFFORT\x10\x03\x12#\n" + + "\x1fTHINKING_MODE_CONTINUOUS_BUDGET\x10\x04*\x8a\x01\n" + + "\vCachingMode\x12\x1c\n" + + "\x18CACHING_MODE_UNSPECIFIED\x10\x00\x12\x15\n" + + "\x11CACHING_MODE_NONE\x10\x01\x12!\n" + + "\x1dCACHING_MODE_EXPLICIT_MARKERS\x10\x02\x12#\n" + + "\x1fCACHING_MODE_IMPLICIT_AUTOMATIC\x10\x03*\xa1\x01\n" + + "\x0eToolChoiceMode\x12 \n" + + "\x1cTOOL_CHOICE_MODE_UNSPECIFIED\x10\x00\x12\x19\n" + + "\x15TOOL_CHOICE_MODE_AUTO\x10\x01\x12\x18\n" + + "\x14TOOL_CHOICE_MODE_ANY\x10\x02\x12\x19\n" + + "\x15TOOL_CHOICE_MODE_NONE\x10\x03\x12\x1d\n" + + "\x19TOOL_CHOICE_MODE_SPECIFIC\x10\x04B>Z pluggableharness.model.v1.ModelSpec + 19, // 1: pluggableharness.model.v1.Capabilities.slash_commands:type_name -> pluggableharness.common.v1.PromptExpansionSpec + 20, // 2: pluggableharness.model.v1.Capabilities.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema + 21, // 3: pluggableharness.model.v1.Capabilities.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint + 6, // 4: pluggableharness.model.v1.ModelSpec.thinking:type_name -> pluggableharness.model.v1.ThinkingSpec + 7, // 5: pluggableharness.model.v1.ModelSpec.caching:type_name -> pluggableharness.model.v1.CachingSpec + 9, // 6: pluggableharness.model.v1.ModelSpec.pricing:type_name -> pluggableharness.model.v1.Pricing + 2, // 7: pluggableharness.model.v1.ModelSpec.supported_tool_choice_modes:type_name -> pluggableharness.model.v1.ToolChoiceMode + 0, // 8: pluggableharness.model.v1.ThinkingSpec.mode:type_name -> pluggableharness.model.v1.ThinkingMode + 5, // 9: pluggableharness.model.v1.ThinkingSpec.budget_range:type_name -> pluggableharness.model.v1.ThinkingBudgetRange + 1, // 10: pluggableharness.model.v1.CachingSpec.mode:type_name -> pluggableharness.model.v1.CachingMode + 22, // 11: pluggableharness.model.v1.PricingTier.effective_from:type_name -> google.protobuf.Timestamp + 22, // 12: pluggableharness.model.v1.PricingTier.effective_until:type_name -> google.protobuf.Timestamp + 8, // 13: pluggableharness.model.v1.Pricing.tiers:type_name -> pluggableharness.model.v1.PricingTier + 17, // 14: pluggableharness.model.v1.CacheBreakpoint.after_assembled_context:type_name -> pluggableharness.model.v1.CacheBreakpoint.AfterAssembledContext + 18, // 15: pluggableharness.model.v1.CacheBreakpoint.after_tools:type_name -> pluggableharness.model.v1.CacheBreakpoint.AfterTools + 23, // 16: pluggableharness.model.v1.ToolDeclaration.input_schema:type_name -> pluggableharness.schema.v1.Schema + 13, // 17: pluggableharness.model.v1.GenerationParams.tool_choice:type_name -> pluggableharness.model.v1.ToolChoice + 2, // 18: pluggableharness.model.v1.ToolChoice.mode:type_name -> pluggableharness.model.v1.ToolChoiceMode + 19, // [19:19] is the sub-list for method output_type + 19, // [19:19] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name +} + +func init() { file_pluggableharness_model_v1_types_proto_init() } +func file_pluggableharness_model_v1_types_proto_init() { + if File_pluggableharness_model_v1_types_proto != nil { + return + } + file_pluggableharness_model_v1_types_proto_msgTypes[1].OneofWrappers = []any{} + file_pluggableharness_model_v1_types_proto_msgTypes[3].OneofWrappers = []any{} + file_pluggableharness_model_v1_types_proto_msgTypes[5].OneofWrappers = []any{} + file_pluggableharness_model_v1_types_proto_msgTypes[7].OneofWrappers = []any{ + (*CacheBreakpoint_AfterAssembledContext_)(nil), + (*CacheBreakpoint_AfterTools_)(nil), + (*CacheBreakpoint_AfterMessageIndex)(nil), + } + file_pluggableharness_model_v1_types_proto_msgTypes[9].OneofWrappers = []any{} + file_pluggableharness_model_v1_types_proto_msgTypes[10].OneofWrappers = []any{} + file_pluggableharness_model_v1_types_proto_msgTypes[11].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_model_v1_types_proto_rawDesc), len(file_pluggableharness_model_v1_types_proto_rawDesc)), + NumEnums: 3, + NumMessages: 16, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_model_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_model_v1_types_proto_depIdxs, + EnumInfos: file_pluggableharness_model_v1_types_proto_enumTypes, + MessageInfos: file_pluggableharness_model_v1_types_proto_msgTypes, + }.Build() + File_pluggableharness_model_v1_types_proto = out.File + file_pluggableharness_model_v1_types_proto_goTypes = nil + file_pluggableharness_model_v1_types_proto_depIdxs = nil +} diff --git a/pkg/model/server.go b/pkg/model/server.go new file mode 100644 index 0000000..e667361 --- /dev/null +++ b/pkg/model/server.go @@ -0,0 +1,130 @@ +package model + +import ( + "context" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" +) + +// Service adapts a Provider into the generated modelv1.ModelServiceServer +// and satisfies pkg/plugin.Service, so it can be passed directly to +// plugin.Config.Services. Construct one with NewService. +type Service struct { + modelv1.UnimplementedModelServiceServer + + provider Provider + identity plugin.Identity + callback *plugin.Callback +} + +var ( + _ plugin.Service = (*Service)(nil) + _ modelv1.ModelServiceServer = (*Service)(nil) +) + +// NewService builds a Service wrapping p. identity is this plugin build's +// own self-reported identity, used to answer Describe +// (docs/specifications/model/protocol.md#describe) — implemented here +// directly from identity.ProducerRef, no author code required. callback is +// the lazily-dialed handle to the kernel callback channel +// (pkg/plugin.NewCallback); Service does not itself call into it — it is +// threaded through so a future RPC handler needing kernel-callback access +// (e.g. a CountTokens fallback path) has it available without changing +// this constructor's signature. +// +// CountTokens and Render are optional per +// docs/specifications/model/conformance.md's summary matrix (SHOULD, MAY +// respectively). Whether p supports them is detected with a type +// assertion against TokenCounter/Renderer at call time, once per RPC — +// the standard library's optional-interface pattern (io.ReaderFrom, +// io.WriterTo) — rather than a boolean flag on Provider itself that an +// author could forget to keep in sync with their own method set, or a +// second required constructor parameter that would force every author to +// write "false"/nil for RPCs their backend doesn't support. +func NewService(p Provider, identity plugin.Identity, callback *plugin.Callback) *Service { + return &Service{provider: p, identity: identity, callback: callback} +} + +// Register registers ModelService on s, satisfying pkg/plugin.Service. +func (svc *Service) Register(s *grpc.Server) { + modelv1.RegisterModelServiceServer(s, svc) +} + +// Describe reports this plugin build's own identity, per +// docs/specifications/model/protocol.md#describe. Implemented directly +// from svc.identity — no Provider method is involved. +func (svc *Service) Describe(_ context.Context, _ *modelv1.DescribeRequest) (*modelv1.DescribeResponse, error) { + return &modelv1.DescribeResponse{ + Producer: svc.identity.ProducerRef(commonv1.Category_CATEGORY_MODEL), + }, nil +} + +// GetCapabilities delegates to svc.provider.Capabilities and converts the +// result to the wire type. +func (svc *Service) GetCapabilities(ctx context.Context, _ *modelv1.GetCapabilitiesRequest) (*modelv1.GetCapabilitiesResponse, error) { + caps, err := svc.provider.Capabilities(ctx) + if err != nil { + return nil, statusFromErr(err) + } + return &modelv1.GetCapabilitiesResponse{Capabilities: capabilitiesToProto(caps)}, nil +} + +// Configure delegates to svc.provider.Configure. +func (svc *Service) Configure(ctx context.Context, req *modelv1.ConfigureRequest) (*modelv1.ConfigureResponse, error) { + if err := svc.provider.Configure(ctx, req.GetConfig()); err != nil { + return nil, statusFromErr(err) + } + return &modelv1.ConfigureResponse{}, nil +} + +// StreamCompletion delegates to svc.provider.StreamCompletion, handing it +// a Sink wrapping stream. Cancellation — the kernel closing the gRPC +// stream — is treated as normal control flow: a returned context.Canceled +// (or any error already carrying codes.Canceled, e.g. one Sink itself +// returned) becomes a bare codes.Canceled status, never routed through +// Error's category-based mapping. +func (svc *Service) StreamCompletion(req *modelv1.StreamCompletionRequest, stream modelv1.ModelService_StreamCompletionServer) error { + sink := newSink(stream) + if err := svc.provider.StreamCompletion(stream.Context(), req, sink); err != nil { + return statusFromErr(err) + } + return nil +} + +// CountTokens delegates to svc.provider when it implements TokenCounter, +// per docs/specifications/model/protocol.md#counttokens (SHOULD). A +// Provider that doesn't implement TokenCounter returns codes.Unimplemented +// here, and the kernel falls back to its documented heuristic. +func (svc *Service) CountTokens(ctx context.Context, req *modelv1.CountTokensRequest) (*modelv1.CountTokensResponse, error) { + tc, ok := svc.provider.(TokenCounter) + if !ok { + return nil, status.Error(codes.Unimplemented, "model: CountTokens not implemented by this provider") + } + count, err := tc.CountTokens(ctx, req.GetText(), req.GetModelId()) + if err != nil { + return nil, statusFromErr(err) + } + return &modelv1.CountTokensResponse{Count: count}, nil +} + +// Render delegates to svc.provider when it implements Renderer, per +// docs/specifications/model/protocol.md#render (MAY). A Provider that +// doesn't implement Renderer returns codes.Unimplemented here, and the +// kernel falls back to its generic default rendering. +func (svc *Service) Render(ctx context.Context, req *modelv1.RenderRequest) (*modelv1.RenderResponse, error) { + r, ok := svc.provider.(Renderer) + if !ok { + return nil, status.Error(codes.Unimplemented, "model: Render not implemented by this provider") + } + tree, err := r.Render(ctx, req.GetPayload(), req.GetSchemaVersion()) + if err != nil { + return nil, statusFromErr(err) + } + return &modelv1.RenderResponse{Tree: tree}, nil +} diff --git a/pkg/model/server_test.go b/pkg/model/server_test.go new file mode 100644 index 0000000..1e9476a --- /dev/null +++ b/pkg/model/server_test.go @@ -0,0 +1,533 @@ +package model_test + +import ( + "context" + "errors" + "io" + "net" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + grpcstatus "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/types/known/structpb" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + model "github.com/pluggableharness/agent/pkg/model" + modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// fakeProvider is a hand-written model.Provider (go-testing.md: fakes, not +// mocking frameworks). Each RPC's behavior is controlled by a caller-set +// func field; a nil field falls back to a minimal default. Embedding +// pointers to *bool-style toggles keeps the zero value ("all defaults") +// usable without every test having to populate every field. +type fakeProvider struct { + capabilitiesFunc func(ctx context.Context) (*model.Capabilities, error) + configureFunc func(ctx context.Context, config *structpb.Struct) error + streamCompletionFunc func(ctx context.Context, req *modelv1.StreamCompletionRequest, sink *model.Sink) error +} + +func (f *fakeProvider) Capabilities(ctx context.Context) (*model.Capabilities, error) { + if f.capabilitiesFunc != nil { + return f.capabilitiesFunc(ctx) + } + return model.NewCapabilities([]model.Spec{{ + ID: "fake-model", + Thinking: model.ThinkingSpec{Mode: modelv1.ThinkingMode_THINKING_MODE_NONE}, + Caching: model.CachingSpec{Mode: modelv1.CachingMode_CACHING_MODE_NONE}, + Pricing: model.Pricing{Currency: "USD", Free: true}, + }}, &configv1.ConfigSchema{}) +} + +func (f *fakeProvider) Configure(ctx context.Context, config *structpb.Struct) error { + if f.configureFunc != nil { + return f.configureFunc(ctx, config) + } + return nil +} + +func (f *fakeProvider) StreamCompletion(ctx context.Context, req *modelv1.StreamCompletionRequest, sink *model.Sink) error { + if f.streamCompletionFunc != nil { + return f.streamCompletionFunc(ctx, req, sink) + } + if err := sink.TextDelta("hello"); err != nil { + return err + } + return sink.Stop(modelv1.StopReason_STOP_REASON_END_TURN, "") +} + +var _ model.Provider = (*fakeProvider)(nil) + +// fakeTokenCounterProvider embeds fakeProvider and additionally implements +// model.TokenCounter, so server_test.go can exercise both "provider +// implements the optional RPC" and "provider doesn't" without two +// unrelated fake types. +type fakeTokenCounterProvider struct { + fakeProvider + countTokensFunc func(ctx context.Context, text, modelID string) (int64, error) +} + +func (f *fakeTokenCounterProvider) CountTokens(ctx context.Context, text, modelID string) (int64, error) { + if f.countTokensFunc != nil { + return f.countTokensFunc(ctx, text, modelID) + } + return int64(len(text)), nil +} + +var _ model.TokenCounter = (*fakeTokenCounterProvider)(nil) + +// fakeRendererProvider is Render's analog to fakeTokenCounterProvider. +type fakeRendererProvider struct { + fakeProvider + renderFunc func(ctx context.Context, payload []byte, schemaVersion string) (*renderv1.RenderTree, error) +} + +func (f *fakeRendererProvider) Render(ctx context.Context, payload []byte, schemaVersion string) (*renderv1.RenderTree, error) { + if f.renderFunc != nil { + return f.renderFunc(ctx, payload, schemaVersion) + } + return &renderv1.RenderTree{}, nil +} + +var _ model.Renderer = (*fakeRendererProvider)(nil) + +// newTestClient starts a Service wrapping p on an in-memory bufconn +// listener and returns a modelv1.ModelServiceClient dialed against it — a +// real gRPC round trip, not a hand-rolled interface fake, per +// pkg/kernel/helpers_test.go's newTestClient shape. +func newTestClient(t *testing.T, p model.Provider) modelv1.ModelServiceClient { + t.Helper() + + const bufSize = 1 << 20 + lis := bufconn.Listen(bufSize) + + svc := model.NewService(p, plugin.Identity{Name: "fake", Version: "1.0.0", Source: "github.com/pluggableharness/agent/pkg/model/testdata"}, plugin.NewCallback()) + + gs := grpc.NewServer() + svc.Register(gs) + go func() { _ = gs.Serve(lis) }() + t.Cleanup(gs.Stop) + + dialer := func(context.Context, string) (net.Conn, error) { return lis.Dial() } + conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(dialer), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("grpc.NewClient: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + return modelv1.NewModelServiceClient(conn) +} + +func TestService_Describe(t *testing.T) { + t.Parallel() + + client := newTestClient(t, &fakeProvider{}) + resp, err := client.Describe(t.Context(), &modelv1.DescribeRequest{}) + if err != nil { + t.Fatalf("Describe() = %v, want nil error", err) + } + producer := resp.GetProducer() + if producer.GetName() != "fake" || producer.GetVersion() != "1.0.0" { + t.Errorf("producer = %+v, want name=fake version=1.0.0", producer) + } + if producer.GetCategory() != commonv1.Category_CATEGORY_MODEL { + t.Errorf("producer.Category = %v, want CATEGORY_MODEL", producer.GetCategory()) + } +} + +func TestService_GetCapabilities(t *testing.T) { + t.Parallel() + + client := newTestClient(t, &fakeProvider{}) + resp, err := client.GetCapabilities(t.Context(), &modelv1.GetCapabilitiesRequest{}) + if err != nil { + t.Fatalf("GetCapabilities() = %v, want nil error", err) + } + models := resp.GetCapabilities().GetModels() + if len(models) != 1 || models[0].GetId() != "fake-model" { + t.Errorf("models = %+v, want one model fake-model", models) + } +} + +func TestService_GetCapabilities_ProviderError(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + capabilitiesFunc: func(context.Context) (*model.Capabilities, error) { + return nil, &model.Error{Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_OVERLOADED, Message: "vendor down"} + }, + } + client := newTestClient(t, p) + _, err := client.GetCapabilities(t.Context(), &modelv1.GetCapabilitiesRequest{}) + if grpcstatus.Code(err) != codes.Unavailable { + t.Errorf("code = %v, want codes.Unavailable", grpcstatus.Code(err)) + } +} + +func TestService_Configure(t *testing.T) { + t.Parallel() + + var gotConfig *structpb.Struct + p := &fakeProvider{ + configureFunc: func(_ context.Context, config *structpb.Struct) error { + gotConfig = config + return nil + }, + } + client := newTestClient(t, p) + cfg, err := structpb.NewStruct(map[string]any{"api_key": "secret"}) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + if _, err := client.Configure(t.Context(), &modelv1.ConfigureRequest{Config: cfg}); err != nil { + t.Fatalf("Configure() = %v, want nil error", err) + } + if gotConfig.GetFields()["api_key"].GetStringValue() != "secret" { + t.Errorf("provider did not receive the config it was sent") + } +} + +func TestService_Configure_MissingRequiredField(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + configureFunc: func(context.Context, *structpb.Struct) error { + return &model.Error{Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_AUTH_ERROR, Message: "missing api_key"} + }, + } + client := newTestClient(t, p) + _, err := client.Configure(t.Context(), &modelv1.ConfigureRequest{Config: &structpb.Struct{}}) + if grpcstatus.Code(err) != codes.Unauthenticated { + t.Errorf("code = %v, want codes.Unauthenticated", grpcstatus.Code(err)) + } +} + +func TestService_StreamCompletion_HappyPath(t *testing.T) { + t.Parallel() + + client := newTestClient(t, &fakeProvider{}) + stream, err := client.StreamCompletion(t.Context(), &modelv1.StreamCompletionRequest{ModelId: "fake-model"}) + if err != nil { + t.Fatalf("StreamCompletion() = %v, want nil error", err) + } + + var events []*modelv1.StreamEvent + for { + ev, err := stream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("stream.Recv() = %v, want nil or io.EOF", err) + } + events = append(events, ev) + } + + if len(events) != 2 { + t.Fatalf("len(events) = %d, want 2", len(events)) + } + if events[0].GetTextDelta().GetText() != "hello" { + t.Errorf("events[0].TextDelta.Text = %q, want %q", events[0].GetTextDelta().GetText(), "hello") + } + if events[1].GetStop().GetReason() != modelv1.StopReason_STOP_REASON_END_TURN { + t.Errorf("events[1].Stop.Reason = %v, want STOP_REASON_END_TURN", events[1].GetStop().GetReason()) + } +} + +func TestService_StreamCompletion_InBandError(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + streamCompletionFunc: func(_ context.Context, _ *modelv1.StreamCompletionRequest, sink *model.Sink) error { + return sink.Error(&model.Error{ + Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED, + Message: "conversation too long", + }) + }, + } + client := newTestClient(t, p) + stream, err := client.StreamCompletion(t.Context(), &modelv1.StreamCompletionRequest{ModelId: "fake-model"}) + if err != nil { + t.Fatalf("StreamCompletion() = %v, want nil error", err) + } + ev, err := stream.Recv() + if err != nil { + t.Fatalf("stream.Recv() = %v, want nil", err) + } + if ev.GetError().GetError().GetCategory() != modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED { + t.Errorf("category = %v, want MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED", ev.GetError().GetError().GetCategory()) + } + if _, err := stream.Recv(); !errors.Is(err, io.EOF) { + t.Errorf("stream.Recv() after the in-band error = %v, want io.EOF", err) + } +} + +func TestService_StreamCompletion_ProviderReturnsModelError(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + streamCompletionFunc: func(context.Context, *modelv1.StreamCompletionRequest, *model.Sink) error { + return &model.Error{Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, Message: "malformed request"} + }, + } + client := newTestClient(t, p) + stream, err := client.StreamCompletion(t.Context(), &modelv1.StreamCompletionRequest{ModelId: "fake-model"}) + if err != nil { + t.Fatalf("StreamCompletion() = %v, want nil error", err) + } + _, err = stream.Recv() + if grpcstatus.Code(err) != codes.InvalidArgument { + t.Errorf("code = %v, want codes.InvalidArgument", grpcstatus.Code(err)) + } +} + +// TestService_StreamCompletion_Cancellation exercises the streaming +// cancellation path explicitly, per the task brief: a Provider.StreamCompletion +// that blocks until ctx.Done() and the test cancels the client-side +// context, confirming codes.Canceled propagates cleanly, not as an +// internal error. +func TestService_StreamCompletion_Cancellation(t *testing.T) { + t.Parallel() + + started := make(chan struct{}) + p := &fakeProvider{ + streamCompletionFunc: func(ctx context.Context, _ *modelv1.StreamCompletionRequest, _ *model.Sink) error { + close(started) + <-ctx.Done() + return ctx.Err() + }, + } + client := newTestClient(t, p) + + ctx, cancel := context.WithCancel(t.Context()) + stream, err := client.StreamCompletion(ctx, &modelv1.StreamCompletionRequest{ModelId: "fake-model"}) + if err != nil { + t.Fatalf("StreamCompletion() = %v, want nil error", err) + } + + go func() { + <-started + cancel() + }() + + _, err = stream.Recv() + if grpcstatus.Code(err) != codes.Canceled { + t.Errorf("code = %v, want codes.Canceled", grpcstatus.Code(err)) + } +} + +// TestService_StreamCompletion_CapabilityGatedContentRejection exercises +// docs/specifications/model/data-types.md#canonical-message--content-block-schema's +// rule that image/document content sent to a model without the matching +// capability flag MUST be rejected with a structured error, not silently +// dropped or a panic — driven through a fakeProvider that performs exactly +// this check against its own advertised Spec before generating, +// mirroring what a real adapter is expected to do. +func TestService_StreamCompletion_CapabilityGatedContentRejection(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content *contentv1.ContentBlock + }{ + { + name: "image without supports_vision", + content: &contentv1.ContentBlock{ + Block: &contentv1.ContentBlock_Image{Image: &contentv1.ImageBlock{Data: []byte("png"), MediaType: "image/png"}}, + }, + }, + { + name: "document without supports_documents", + content: &contentv1.ContentBlock{ + Block: &contentv1.ContentBlock_Document{Document: &contentv1.DocumentBlock{Data: []byte("pdf"), MediaType: "application/pdf"}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + streamCompletionFunc: func(_ context.Context, req *modelv1.StreamCompletionRequest, sink *model.Sink) error { + for _, msg := range req.GetMessages() { + for _, block := range msg.GetContent() { + if block.GetImage() != nil { + return sink.Error(&model.Error{ + Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, + Message: "model does not support image content", + }) + } + if block.GetDocument() != nil { + return sink.Error(&model.Error{ + Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, + Message: "model does not support document content", + }) + } + } + } + return sink.Stop(modelv1.StopReason_STOP_REASON_END_TURN, "") + }, + } + client := newTestClient(t, p) + req := &modelv1.StreamCompletionRequest{ + ModelId: "fake-model", + Messages: []*contentv1.Message{ + {Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{tt.content}}, + }, + } + stream, err := client.StreamCompletion(t.Context(), req) + if err != nil { + t.Fatalf("StreamCompletion() = %v, want nil error", err) + } + ev, err := stream.Recv() + if err != nil { + t.Fatalf("stream.Recv() = %v, want nil", err) + } + if ev.GetError() == nil { + t.Fatalf("event = %+v, want an in-band error event, not a silent drop", ev) + } + if ev.GetError().GetError().GetCategory() != modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST { + t.Errorf("category = %v, want MODEL_ERROR_CATEGORY_INVALID_REQUEST", ev.GetError().GetError().GetCategory()) + } + }) + } +} + +func TestService_CountTokens_Implemented(t *testing.T) { + t.Parallel() + + client := newTestClient(t, &fakeTokenCounterProvider{}) + resp, err := client.CountTokens(t.Context(), &modelv1.CountTokensRequest{Text: "hello world", ModelId: "fake-model"}) + if err != nil { + t.Fatalf("CountTokens() = %v, want nil error", err) + } + if resp.GetCount() != int64(len("hello world")) { + t.Errorf("Count = %d, want %d", resp.GetCount(), len("hello world")) + } +} + +func TestService_CountTokens_NotImplemented(t *testing.T) { + t.Parallel() + + client := newTestClient(t, &fakeProvider{}) + _, err := client.CountTokens(t.Context(), &modelv1.CountTokensRequest{Text: "hi", ModelId: "fake-model"}) + if grpcstatus.Code(err) != codes.Unimplemented { + t.Errorf("code = %v, want codes.Unimplemented", grpcstatus.Code(err)) + } +} + +func TestService_CountTokens_ProviderError(t *testing.T) { + t.Parallel() + + p := &fakeTokenCounterProvider{ + countTokensFunc: func(context.Context, string, string) (int64, error) { + return 0, &model.Error{Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, Message: "unknown model"} + }, + } + client := newTestClient(t, p) + _, err := client.CountTokens(t.Context(), &modelv1.CountTokensRequest{Text: "hi", ModelId: "nope"}) + if grpcstatus.Code(err) != codes.InvalidArgument { + t.Errorf("code = %v, want codes.InvalidArgument", grpcstatus.Code(err)) + } +} + +func TestService_Render_Implemented(t *testing.T) { + t.Parallel() + + client := newTestClient(t, &fakeRendererProvider{}) + resp, err := client.Render(t.Context(), &modelv1.RenderRequest{Payload: []byte("{}"), SchemaVersion: "v1"}) + if err != nil { + t.Fatalf("Render() = %v, want nil error", err) + } + if resp.GetTree() == nil { + t.Errorf("Tree = nil, want a RenderTree") + } +} + +func TestService_Render_NotImplemented(t *testing.T) { + t.Parallel() + + client := newTestClient(t, &fakeProvider{}) + _, err := client.Render(t.Context(), &modelv1.RenderRequest{Payload: []byte("{}"), SchemaVersion: "v1"}) + if grpcstatus.Code(err) != codes.Unimplemented { + t.Errorf("code = %v, want codes.Unimplemented", grpcstatus.Code(err)) + } +} + +func TestService_Render_ProviderError(t *testing.T) { + t.Parallel() + + p := &fakeRendererProvider{ + renderFunc: func(context.Context, []byte, string) (*renderv1.RenderTree, error) { + return nil, errors.New("unknown schema version") + }, + } + client := newTestClient(t, p) + _, err := client.Render(t.Context(), &modelv1.RenderRequest{Payload: []byte("{}"), SchemaVersion: "v999"}) + if grpcstatus.Code(err) != codes.Internal { + t.Errorf("code = %v, want codes.Internal", grpcstatus.Code(err)) + } +} + +// TestService_StreamCompletion_NonStreamingBackend exercises +// docs/specifications/model/protocol.md#streamcompletion's rule that a +// batch-only backend still implements the streaming RPC shape, emitting +// its full response as a single terminal burst of events followed by a +// Stop — modeled here as a Provider that sends every event before +// returning, with no incremental delay. +func TestService_StreamCompletion_NonStreamingBackend(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + streamCompletionFunc: func(_ context.Context, _ *modelv1.StreamCompletionRequest, sink *model.Sink) error { + if err := sink.TextDelta("the whole response at once"); err != nil { + return err + } + one := int64(1) + if err := sink.Usage(model.Usage{InputTokens: 10, OutputTokens: 6, ReasoningTokens: &one}); err != nil { + return err + } + return sink.Stop(modelv1.StopReason_STOP_REASON_END_TURN, "") + }, + } + client := newTestClient(t, p) + stream, err := client.StreamCompletion(t.Context(), &modelv1.StreamCompletionRequest{ModelId: "fake-model"}) + if err != nil { + t.Fatalf("StreamCompletion() = %v, want nil error", err) + } + + var events []*modelv1.StreamEvent + for { + ev, err := stream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("stream.Recv() = %v, want nil or io.EOF", err) + } + events = append(events, ev) + } + if len(events) != 3 { + t.Fatalf("len(events) = %d, want 3 (text_delta, usage, stop)", len(events)) + } + if events[2].GetStop() == nil { + t.Errorf("final event = %+v, want a Stop", events[2]) + } +} + +func TestNewService(t *testing.T) { + t.Parallel() + + svc := model.NewService(&fakeProvider{}, plugin.Identity{Name: "x"}, plugin.NewCallback()) + if svc == nil { + t.Fatal("NewService() = nil") + } +} diff --git a/pkg/model/stream.go b/pkg/model/stream.go new file mode 100644 index 0000000..f828fee --- /dev/null +++ b/pkg/model/stream.go @@ -0,0 +1,169 @@ +package model + +import ( + "context" + "sync" + + modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" +) + +// Sink is the cancellation-safe StreamCompletion event writer handed to +// Provider.StreamCompletion. Exactly one terminal event — Stop or Error — +// may be sent per stream, per docs/specifications/model/data-types.md#streamevent; +// a call after the first terminal event returns ErrStreamAlreadyTerminated +// without touching the wire. Safe for concurrent use, though a Provider +// implementation typically drives it from a single goroutine. +type Sink struct { + stream modelv1.ModelService_StreamCompletionServer + + mu sync.Mutex + terminal bool +} + +// newSink wraps stream. Unexported: a Provider never constructs a Sink +// itself, only receives one from server.go's StreamCompletion handler. +func newSink(stream modelv1.ModelService_StreamCompletionServer) *Sink { + return &Sink{stream: stream} +} + +// Context returns the stream's context — cancelled when the kernel closes +// the gRPC stream (docs/specifications/model/README.md#transport--lifecycle). +// A Provider's StreamCompletion loop SHOULD select on this (or the ctx +// argument it was called with, which is the same context) to stop +// generating promptly. Not cached on Sink itself +// (.claude/rules/go-architecture.md's "never store a context.Context in a +// struct field") — grpc.ServerStream.Context() is a cheap getter, so +// re-deriving it per call costs nothing. +func (s *Sink) Context() context.Context { + return s.stream.Context() +} + +// send writes ev, honoring the terminal-event and cancellation invariants +// documented on Sink. terminal marks ev itself as the stream's terminal +// event when true (Stop, Error) — recorded before the write is attempted, +// so a failed or racing second terminal call never gets a chance to send. +func (s *Sink) send(ev *modelv1.StreamEvent, terminal bool) error { + s.mu.Lock() + if s.terminal { + s.mu.Unlock() + return ErrStreamAlreadyTerminated + } + if terminal { + s.terminal = true + } + s.mu.Unlock() + + if err := s.stream.Context().Err(); err != nil { + // Cancellation is normal control flow, never an error condition — + // docs/specifications/model/README.md#transport--lifecycle, + // .claude/rules/grpc.md's cancellation rule. Returned as-is so a + // caller's errors.Is(err, context.Canceled) works. + return err + } + return s.stream.Send(ev) +} + +// TextDelta sends an incremental fragment of assistant text output. MUST +// be supported by every plugin. +func (s *Sink) TextDelta(text string) error { + return s.send(&modelv1.StreamEvent{ + Event: &modelv1.StreamEvent_TextDelta_{ + TextDelta: &modelv1.StreamEvent_TextDelta{Text: text}, + }, + }, false) +} + +// ThinkingDelta sends an incremental fragment of the model's reasoning +// output. Only meaningful when the target model's ThinkingSpec.Supported. +func (s *Sink) ThinkingDelta(text string) error { + return s.send(&modelv1.StreamEvent{ + Event: &modelv1.StreamEvent_ThinkingDelta_{ + ThinkingDelta: &modelv1.StreamEvent_ThinkingDelta{Text: text}, + }, + }, false) +} + +// ThinkingSignature sends the vendor's opaque integrity token for the +// reasoning block just completed. MUST be sent if the vendor's thinking +// blocks carry an integrity signature — the kernel stores and echoes it +// back verbatim, never inspecting or reformatting it. +func (s *Sink) ThinkingSignature(signature []byte) error { + return s.send(&modelv1.StreamEvent{ + Event: &modelv1.StreamEvent_ThinkingSignature_{ + ThinkingSignature: &modelv1.StreamEvent_ThinkingSignature{Signature: signature}, + }, + }, false) +} + +// ToolCallStart announces the model has begun requesting a tool +// invocation. id correlates the matching ToolCallDelta/ToolCallDone calls +// and the resulting ToolUseBlock.id; name is the tool's declared name. +func (s *Sink) ToolCallStart(id, name string) error { + return s.send(&modelv1.StreamEvent{ + Event: &modelv1.StreamEvent_ToolCallStart_{ + ToolCallStart: &modelv1.StreamEvent_ToolCallStart{Id: id, Name: name}, + }, + }, false) +} + +// ToolCallDelta sends one incremental fragment of a tool call's arguments, +// accumulated by the kernel across deltas into the final parsed JSON. +func (s *Sink) ToolCallDelta(id, argumentsFragment string) error { + return s.send(&modelv1.StreamEvent{ + Event: &modelv1.StreamEvent_ToolCallDelta_{ + ToolCallDelta: &modelv1.StreamEvent_ToolCallDelta{Id: id, ArgumentsFragment: argumentsFragment}, + }, + }, false) +} + +// ToolCallDone signals a tool call's arguments are complete and ready for +// the kernel to parse and dispatch. +func (s *Sink) ToolCallDone(id string) error { + return s.send(&modelv1.StreamEvent{ + Event: &modelv1.StreamEvent_ToolCallDone_{ + ToolCallDone: &modelv1.StreamEvent_ToolCallDone{Id: id}, + }, + }, false) +} + +// Usage sends token accounting for this completion. The kernel computes +// and persists cost_usd from these counts plus the matching PricingTier — +// a Provider never computes cost itself +// (docs/specifications/model/protocol.md#cost-computation). +func (s *Sink) Usage(u Usage) error { + return s.send(&modelv1.StreamEvent{ + Event: &modelv1.StreamEvent_Usage{Usage: usageToProto(u)}, + }, false) +} + +// Stop sends the stream's terminal Stop event and closes the sink to +// further sends. matchedStopSequence MUST be non-empty iff +// reason == STOP_REASON_STOP_SEQUENCE; Stop ignores it (leaves it unset on +// the wire) for every other reason rather than erroring, since a Provider +// passing a stray value alongside an unrelated reason is a harmless +// authoring mistake, not a wire-protocol violation worth failing the whole +// stream over. +func (s *Sink) Stop(reason modelv1.StopReason, matchedStopSequence string) error { + stop := &modelv1.StreamEvent_Stop{Reason: reason} + if reason == modelv1.StopReason_STOP_REASON_STOP_SEQUENCE && matchedStopSequence != "" { + seq := matchedStopSequence + stop.MatchedStopSequence = &seq + } + return s.send(&modelv1.StreamEvent{ + Event: &modelv1.StreamEvent_Stop_{Stop: stop}, + }, true) +} + +// Error sends the stream's terminal Error event — a plugin classifying a +// failure *within* an otherwise-open stream, per +// docs/specifications/model/data-types.md#streamevent, distinct from the +// stream being torn down at the transport level. A backend that fails +// outright before producing any events MAY call this with no preceding +// event at all. +func (s *Sink) Error(modelErr *Error) error { + return s.send(&modelv1.StreamEvent{ + Event: &modelv1.StreamEvent_Error_{ + Error: &modelv1.StreamEvent_Error{Error: modelErr.toProto()}, + }, + }, true) +} diff --git a/pkg/model/stream_internal_test.go b/pkg/model/stream_internal_test.go new file mode 100644 index 0000000..b703759 --- /dev/null +++ b/pkg/model/stream_internal_test.go @@ -0,0 +1,217 @@ +package model + +import ( + "context" + "errors" + "sync" + "testing" + + "google.golang.org/grpc/metadata" + + modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" +) + +// fakeServerStream is a hand-written modelv1.ModelService_StreamCompletionServer +// fake (go-testing.md: fakes, not mocking frameworks) — enough of +// grpc.ServerStream to exercise Sink's own logic without a real network +// round trip; server_test.go covers the real bufconn path end to end. +type fakeServerStream struct { + ctx context.Context //nolint:containedctx // test fixture standing in for the real grpc.ServerStream, whose own Context() is likewise just a stored field under the hood. + + mu sync.Mutex + sent []*modelv1.StreamEvent + sendErr error +} + +func newFakeServerStream(ctx context.Context) *fakeServerStream { + return &fakeServerStream{ctx: ctx} +} + +func (f *fakeServerStream) Send(ev *modelv1.StreamEvent) error { + if f.sendErr != nil { + return f.sendErr + } + f.mu.Lock() + defer f.mu.Unlock() + f.sent = append(f.sent, ev) + return nil +} + +func (f *fakeServerStream) events() []*modelv1.StreamEvent { + f.mu.Lock() + defer f.mu.Unlock() + return append([]*modelv1.StreamEvent(nil), f.sent...) +} + +func (f *fakeServerStream) SetHeader(metadata.MD) error { return nil } +func (f *fakeServerStream) SendHeader(metadata.MD) error { return nil } +func (f *fakeServerStream) SetTrailer(metadata.MD) {} +func (f *fakeServerStream) Context() context.Context { return f.ctx } +func (f *fakeServerStream) SendMsg(any) error { return nil } +func (f *fakeServerStream) RecvMsg(any) error { return nil } + +var _ modelv1.ModelService_StreamCompletionServer = (*fakeServerStream)(nil) + +func TestSink_EventVariants(t *testing.T) { + t.Parallel() + + stream := newFakeServerStream(t.Context()) + sink := newSink(stream) + + if err := sink.TextDelta("hello"); err != nil { + t.Fatalf("TextDelta() = %v, want nil", err) + } + if err := sink.ThinkingDelta("reasoning"); err != nil { + t.Fatalf("ThinkingDelta() = %v, want nil", err) + } + if err := sink.ThinkingSignature([]byte("sig")); err != nil { + t.Fatalf("ThinkingSignature() = %v, want nil", err) + } + if err := sink.ToolCallStart("call-1", "read_file"); err != nil { + t.Fatalf("ToolCallStart() = %v, want nil", err) + } + if err := sink.ToolCallDelta("call-1", `{"path":`); err != nil { + t.Fatalf("ToolCallDelta() = %v, want nil", err) + } + if err := sink.ToolCallDone("call-1"); err != nil { + t.Fatalf("ToolCallDone() = %v, want nil", err) + } + reasoning := int64(5) + if err := sink.Usage(Usage{InputTokens: 10, OutputTokens: 20, ReasoningTokens: &reasoning}); err != nil { + t.Fatalf("Usage() = %v, want nil", err) + } + + events := stream.events() + if len(events) != 7 { + t.Fatalf("len(events) = %d, want 7", len(events)) + } + if events[0].GetTextDelta().GetText() != "hello" { + t.Errorf("events[0].TextDelta.Text = %q, want %q", events[0].GetTextDelta().GetText(), "hello") + } + if events[6].GetUsage().GetReasoningTokens() != 5 { + t.Errorf("events[6].Usage.ReasoningTokens = %d, want 5", events[6].GetUsage().GetReasoningTokens()) + } +} + +func TestSink_StopIsTerminal(t *testing.T) { + t.Parallel() + + stream := newFakeServerStream(t.Context()) + sink := newSink(stream) + + if err := sink.Stop(modelv1.StopReason_STOP_REASON_END_TURN, ""); err != nil { + t.Fatalf("first Stop() = %v, want nil", err) + } + if err := sink.TextDelta("too late"); !errors.Is(err, ErrStreamAlreadyTerminated) { + t.Fatalf("TextDelta() after Stop() = %v, want ErrStreamAlreadyTerminated", err) + } + if err := sink.Stop(modelv1.StopReason_STOP_REASON_END_TURN, ""); !errors.Is(err, ErrStreamAlreadyTerminated) { + t.Fatalf("second Stop() = %v, want ErrStreamAlreadyTerminated", err) + } + + events := stream.events() + if len(events) != 1 { + t.Fatalf("len(events) = %d, want 1", len(events)) + } + if events[0].GetStop().GetReason() != modelv1.StopReason_STOP_REASON_END_TURN { + t.Errorf("events[0].Stop.Reason = %v, want STOP_REASON_END_TURN", events[0].GetStop().GetReason()) + } +} + +func TestSink_StopMatchedStopSequence(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + reason modelv1.StopReason + matchedStopSequence string + wantMatchedSequence string + wantSequenceSetOnWire bool + }{ + { + name: "stop sequence reason carries the matched sequence", + reason: modelv1.StopReason_STOP_REASON_STOP_SEQUENCE, + matchedStopSequence: "", + wantMatchedSequence: "", + wantSequenceSetOnWire: true, + }, + { + name: "end_turn reason never carries a matched sequence", + reason: modelv1.StopReason_STOP_REASON_END_TURN, + matchedStopSequence: "", + wantSequenceSetOnWire: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + stream := newFakeServerStream(t.Context()) + sink := newSink(stream) + if err := sink.Stop(tt.reason, tt.matchedStopSequence); err != nil { + t.Fatalf("Stop() = %v, want nil", err) + } + events := stream.events() + stop := events[0].GetStop() + if stop.MatchedStopSequence != nil != tt.wantSequenceSetOnWire { + t.Errorf("MatchedStopSequence set = %v, want %v", stop.MatchedStopSequence != nil, tt.wantSequenceSetOnWire) + } + if tt.wantSequenceSetOnWire && stop.GetMatchedStopSequence() != tt.wantMatchedSequence { + t.Errorf("MatchedStopSequence = %q, want %q", stop.GetMatchedStopSequence(), tt.wantMatchedSequence) + } + }) + } +} + +func TestSink_ErrorIsTerminal(t *testing.T) { + t.Parallel() + + stream := newFakeServerStream(t.Context()) + sink := newSink(stream) + + modelErr := &Error{ + Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_OVERLOADED, + Message: "vendor overloaded", + } + if err := sink.Error(modelErr); err != nil { + t.Fatalf("Error() = %v, want nil", err) + } + if err := sink.Stop(modelv1.StopReason_STOP_REASON_END_TURN, ""); !errors.Is(err, ErrStreamAlreadyTerminated) { + t.Fatalf("Stop() after Error() = %v, want ErrStreamAlreadyTerminated", err) + } + + events := stream.events() + if len(events) != 1 { + t.Fatalf("len(events) = %d, want 1", len(events)) + } + if got := events[0].GetError().GetError().GetMessage(); got != "vendor overloaded" { + t.Errorf("events[0].Error.Error.Message = %q, want %q", got, "vendor overloaded") + } +} + +func TestSink_CancelledContext(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + stream := newFakeServerStream(ctx) + sink := newSink(stream) + + if err := sink.TextDelta("too late"); !errors.Is(err, context.Canceled) { + t.Fatalf("TextDelta() on cancelled context = %v, want context.Canceled", err) + } + if len(stream.events()) != 0 { + t.Errorf("events sent on a cancelled context, want none") + } +} + +func TestSink_Context(t *testing.T) { + t.Parallel() + + stream := newFakeServerStream(t.Context()) + sink := newSink(stream) + if sink.Context() != stream.ctx { + t.Errorf("Context() did not return the wrapped stream's context") + } +} diff --git a/pkg/plan/proto/v1/plan.pb.go b/pkg/plan/proto/v1/types.pb.go similarity index 68% rename from pkg/plan/proto/v1/plan.pb.go rename to pkg/plan/proto/v1/types.pb.go index 359b2bc..a8a40cd 100644 --- a/pkg/plan/proto/v1/plan.pb.go +++ b/pkg/plan/proto/v1/types.pb.go @@ -2,19 +2,21 @@ // versions: // protoc-gen-go v1.36.11 // protoc (unknown) -// source: pluggableharness/plan/v1/plan.proto +// source: pluggableharness/plan/v1/types.proto // Package pluggableharness.plan.v1 defines the plan/apply gate's data types // (specifications/agent-loop.md §5.1). A Plan collects every resource call -// identified in a turn; each PlanItem is evaluated independently against -// policy (agent-loop.md §5.1: three resource calls against three -// different tool providers MUST receive three independently evaluated -// decisions, even if presentation batches them into one approval UI -// interaction). +// identified in a turn — from a tool.v1 provider or a slashcommand.v1 +// provider alike, see PlanItem.producer_category — and each PlanItem is +// evaluated independently against policy (agent-loop.md §5.1: three +// resource calls against three different providers MUST receive three +// independently evaluated decisions, even if presentation batches them +// into one approval UI interaction). package planv1 import ( + v12 "github.com/pluggableharness/agent/pkg/common/proto/v1" v11 "github.com/pluggableharness/agent/pkg/render/proto/v1" v1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" protoreflect "google.golang.org/protobuf/reflect/protoreflect" @@ -89,11 +91,11 @@ func (x PlanDecision) String() string { } func (PlanDecision) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_plan_v1_plan_proto_enumTypes[0].Descriptor() + return file_pluggableharness_plan_v1_types_proto_enumTypes[0].Descriptor() } func (PlanDecision) Type() protoreflect.EnumType { - return &file_pluggableharness_plan_v1_plan_proto_enumTypes[0] + return &file_pluggableharness_plan_v1_types_proto_enumTypes[0] } func (x PlanDecision) Number() protoreflect.EnumNumber { @@ -102,7 +104,7 @@ func (x PlanDecision) Number() protoreflect.EnumNumber { // Deprecated: Use PlanDecision.Descriptor instead. func (PlanDecision) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_plan_v1_plan_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_plan_v1_types_proto_rawDescGZIP(), []int{0} } // ApplyOutcome classifies how one plan item's apply attempt concluded. @@ -160,11 +162,11 @@ func (x ApplyResult_ApplyOutcome) String() string { } func (ApplyResult_ApplyOutcome) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_plan_v1_plan_proto_enumTypes[1].Descriptor() + return file_pluggableharness_plan_v1_types_proto_enumTypes[1].Descriptor() } func (ApplyResult_ApplyOutcome) Type() protoreflect.EnumType { - return &file_pluggableharness_plan_v1_plan_proto_enumTypes[1] + return &file_pluggableharness_plan_v1_types_proto_enumTypes[1] } func (x ApplyResult_ApplyOutcome) Number() protoreflect.EnumNumber { @@ -173,25 +175,35 @@ func (x ApplyResult_ApplyOutcome) Number() protoreflect.EnumNumber { // Deprecated: Use ApplyResult_ApplyOutcome.Descriptor instead. func (ApplyResult_ApplyOutcome) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_plan_v1_plan_proto_rawDescGZIP(), []int{2, 0} + return file_pluggableharness_plan_v1_types_proto_rawDescGZIP(), []int{2, 0} } // PlanItem is one resource (or policy-checked data_source/interactive) -// call awaiting or having received a plan/apply decision. +// call awaiting or having received a plan/apply decision. Produced by +// either a tool.v1 provider (a ToolCall) or a slashcommand.v1 provider +// (a SlashCommandCall) — both flow through the identical plan/apply +// gate, so this message is deliberately provider-category-agnostic +// rather than tool-exclusive; `producer_category` below is what tells a +// consumer which one produced a given item. type PlanItem struct { state protoimpl.MessageState `protogen:"open.v1"` // This item's own id, stable within the plan. Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - // The originating tool call's id (matches pluggableharness.content.v1 - // ToolUseBlock.id / pluggableharness.tool.v1 ToolCall.id). - ToolCallId string `protobuf:"bytes,2,opt,name=tool_call_id,json=toolCallId,proto3" json:"tool_call_id,omitempty"` - // The declared name of the tool provider plugin this call targets. + // The originating call's id (matches pluggableharness.content.v1 + // ToolUseBlock.id, and — depending on producer_category — either + // pluggableharness.tool.v1 ToolCall.id or + // pluggableharness.slashcommand.v1 SlashCommandCall.id). + CallId string `protobuf:"bytes,2,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` + // The declared name of the provider plugin this call targets. Provider string `protobuf:"bytes,3,opt,name=provider,proto3" json:"provider,omitempty"` - // The tool operation being called (tool.md §2 ToolSchema.name). - ToolName string `protobuf:"bytes,4,opt,name=tool_name,json=toolName,proto3" json:"tool_name,omitempty"` - // The call's parsed arguments — the kernel's canonical ToolCall - // representation (model.md §6 / pluggableharness.schema.v1's subset governs - // its shape). A Struct per .claude/rules/proto.md's runtime-JSON + // The operation being called: a pluggableharness.tool.v1.ToolSchema.name + // when producer_category == CATEGORY_TOOL, or a + // pluggableharness.slashcommand.v1.SlashCommandSpec.name when + // producer_category == CATEGORY_SLASHCOMMAND. + OperationName string `protobuf:"bytes,4,opt,name=operation_name,json=operationName,proto3" json:"operation_name,omitempty"` + // The call's parsed arguments — the kernel's canonical representation + // (model.md §6 / pluggableharness.schema.v1's subset governs its + // shape). A Struct per .claude/rules/proto.md's runtime-JSON // carve-out. Input *structpb.Struct `protobuf:"bytes,5,opt,name=input,proto3" json:"input,omitempty"` // This item's current decision. @@ -199,30 +211,42 @@ type PlanItem struct { // The name of the policy rule or subscriber that produced `decision`, // for audit (state-backend.md §4.4 plan_items.decided_by). DecidedBy string `protobuf:"bytes,7,opt,name=decided_by,json=decidedBy,proto3" json:"decided_by,omitempty"` - // Snapshot of ToolSchema.kind (tool/data-types.md#toolschema) for the - // operation this item calls, at plan-construction time. + // Snapshot of the originating operation's kind (ToolSchema.kind or + // SlashCommandSpec.kind — the same pluggableharness.tool.v1.ToolKind + // type either way, per tool/data-types.md#toolschema), at + // plan-construction time. Kind v1.ToolKind `protobuf:"varint,8,opt,name=kind,proto3,enum=pluggableharness.tool.v1.ToolKind" json:"kind,omitempty"` - // Snapshot of ToolSchema.risk, at plan-construction time. + // Snapshot of the originating operation's risk (ToolSchema.risk or + // SlashCommandSpec.risk — the same pluggableharness.tool.v1.RiskClass + // type either way), at plan-construction time. Risk v1.RiskClass `protobuf:"varint,9,opt,name=risk,proto3,enum=pluggableharness.tool.v1.RiskClass" json:"risk,omitempty"` - // Snapshot of ToolSchema.description, at plan-construction time. + // Snapshot of the originating operation's description, at + // plan-construction time. Description string `protobuf:"bytes,10,opt,name=description,proto3" json:"description,omitempty"` - // The tool provider's dry-run preview of this call's effect, when the - // provider implements ToolService.Preview (tool/protocol.md#preview). - // The kernel calls Preview at plan-construction time for + // The provider's dry-run preview of this call's effect, when the + // provider implements Preview (tool/protocol.md#preview or + // slashcommand/protocol.md#preview, per producer_category). The + // kernel calls Preview at plan-construction time for // TOOL_KIND_RESOURCE items whose provider implements it; absent when // the provider has no Preview implementation, in which case a frontend // falls back to rendering the raw `input` above. Pinned to - // render.v1.RenderTree — the exact type ToolService.Preview's own + // render.v1.RenderTree — the exact type either category's Preview // response carries, so this stored snapshot and the RPC's live output // share one wire shape (agent-loop/plan-apply-gate.md#preview-flow). - Preview *v11.RenderTree `protobuf:"bytes,11,opt,name=preview,proto3,oneof" json:"preview,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Preview *v11.RenderTree `protobuf:"bytes,11,opt,name=preview,proto3,oneof" json:"preview,omitempty"` + // Which category produced this item — CATEGORY_TOOL or + // CATEGORY_SLASHCOMMAND today, though nothing about this message + // assumes only those two will ever reach the plan/apply gate. Governs + // which category's Invoke/Preview RPC the kernel calls at apply time + // and how `call_id`/`operation_name` above should be interpreted. + ProducerCategory v12.Category `protobuf:"varint,12,opt,name=producer_category,json=producerCategory,proto3,enum=pluggableharness.common.v1.Category" json:"producer_category,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PlanItem) Reset() { *x = PlanItem{} - mi := &file_pluggableharness_plan_v1_plan_proto_msgTypes[0] + mi := &file_pluggableharness_plan_v1_types_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -234,7 +258,7 @@ func (x *PlanItem) String() string { func (*PlanItem) ProtoMessage() {} func (x *PlanItem) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_plan_v1_plan_proto_msgTypes[0] + mi := &file_pluggableharness_plan_v1_types_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -247,7 +271,7 @@ func (x *PlanItem) ProtoReflect() protoreflect.Message { // Deprecated: Use PlanItem.ProtoReflect.Descriptor instead. func (*PlanItem) Descriptor() ([]byte, []int) { - return file_pluggableharness_plan_v1_plan_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_plan_v1_types_proto_rawDescGZIP(), []int{0} } func (x *PlanItem) GetId() string { @@ -257,9 +281,9 @@ func (x *PlanItem) GetId() string { return "" } -func (x *PlanItem) GetToolCallId() string { +func (x *PlanItem) GetCallId() string { if x != nil { - return x.ToolCallId + return x.CallId } return "" } @@ -271,9 +295,9 @@ func (x *PlanItem) GetProvider() string { return "" } -func (x *PlanItem) GetToolName() string { +func (x *PlanItem) GetOperationName() string { if x != nil { - return x.ToolName + return x.OperationName } return "" } @@ -327,6 +351,13 @@ func (x *PlanItem) GetPreview() *v11.RenderTree { return nil } +func (x *PlanItem) GetProducerCategory() v12.Category { + if x != nil { + return x.ProducerCategory + } + return v12.Category(0) +} + // Plan collects every policy-evaluated call identified during one turn. type Plan struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -340,7 +371,7 @@ type Plan struct { func (x *Plan) Reset() { *x = Plan{} - mi := &file_pluggableharness_plan_v1_plan_proto_msgTypes[1] + mi := &file_pluggableharness_plan_v1_types_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -352,7 +383,7 @@ func (x *Plan) String() string { func (*Plan) ProtoMessage() {} func (x *Plan) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_plan_v1_plan_proto_msgTypes[1] + mi := &file_pluggableharness_plan_v1_types_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -365,7 +396,7 @@ func (x *Plan) ProtoReflect() protoreflect.Message { // Deprecated: Use Plan.ProtoReflect.Descriptor instead. func (*Plan) Descriptor() ([]byte, []int) { - return file_pluggableharness_plan_v1_plan_proto_rawDescGZIP(), []int{1} + return file_pluggableharness_plan_v1_types_proto_rawDescGZIP(), []int{1} } func (x *Plan) GetTurnId() string { @@ -403,7 +434,7 @@ type ApplyResult struct { func (x *ApplyResult) Reset() { *x = ApplyResult{} - mi := &file_pluggableharness_plan_v1_plan_proto_msgTypes[2] + mi := &file_pluggableharness_plan_v1_types_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -415,7 +446,7 @@ func (x *ApplyResult) String() string { func (*ApplyResult) ProtoMessage() {} func (x *ApplyResult) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_plan_v1_plan_proto_msgTypes[2] + mi := &file_pluggableharness_plan_v1_types_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -428,7 +459,7 @@ func (x *ApplyResult) ProtoReflect() protoreflect.Message { // Deprecated: Use ApplyResult.ProtoReflect.Descriptor instead. func (*ApplyResult) Descriptor() ([]byte, []int) { - return file_pluggableharness_plan_v1_plan_proto_rawDescGZIP(), []int{2} + return file_pluggableharness_plan_v1_types_proto_rawDescGZIP(), []int{2} } func (x *ApplyResult) GetTurnId() string { @@ -450,9 +481,9 @@ type ApplyResult_ApplyItem struct { state protoimpl.MessageState `protogen:"open.v1"` // The originating PlanItem.id this outcome is for. MUST be set. PlanItemId string `protobuf:"bytes,1,opt,name=plan_item_id,json=planItemId,proto3" json:"plan_item_id,omitempty"` - // The originating PlanItem.tool_call_id this outcome is for. MUST - // be set. - ToolCallId string `protobuf:"bytes,2,opt,name=tool_call_id,json=toolCallId,proto3" json:"tool_call_id,omitempty"` + // The originating PlanItem.call_id this outcome is for. MUST be + // set. + CallId string `protobuf:"bytes,2,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` // How this item's apply attempt concluded. MUST be set (never // APPLY_OUTCOME_UNSPECIFIED). Outcome ApplyResult_ApplyOutcome `protobuf:"varint,3,opt,name=outcome,proto3,enum=pluggableharness.plan.v1.ApplyResult_ApplyOutcome" json:"outcome,omitempty"` @@ -471,7 +502,7 @@ type ApplyResult_ApplyItem struct { func (x *ApplyResult_ApplyItem) Reset() { *x = ApplyResult_ApplyItem{} - mi := &file_pluggableharness_plan_v1_plan_proto_msgTypes[3] + mi := &file_pluggableharness_plan_v1_types_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -483,7 +514,7 @@ func (x *ApplyResult_ApplyItem) String() string { func (*ApplyResult_ApplyItem) ProtoMessage() {} func (x *ApplyResult_ApplyItem) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_plan_v1_plan_proto_msgTypes[3] + mi := &file_pluggableharness_plan_v1_types_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -496,7 +527,7 @@ func (x *ApplyResult_ApplyItem) ProtoReflect() protoreflect.Message { // Deprecated: Use ApplyResult_ApplyItem.ProtoReflect.Descriptor instead. func (*ApplyResult_ApplyItem) Descriptor() ([]byte, []int) { - return file_pluggableharness_plan_v1_plan_proto_rawDescGZIP(), []int{2, 0} + return file_pluggableharness_plan_v1_types_proto_rawDescGZIP(), []int{2, 0} } func (x *ApplyResult_ApplyItem) GetPlanItemId() string { @@ -506,9 +537,9 @@ func (x *ApplyResult_ApplyItem) GetPlanItemId() string { return "" } -func (x *ApplyResult_ApplyItem) GetToolCallId() string { +func (x *ApplyResult_ApplyItem) GetCallId() string { if x != nil { - return x.ToolCallId + return x.CallId } return "" } @@ -564,17 +595,16 @@ func (*ApplyResult_ApplyItem_ToolResult) isApplyResult_ApplyItem_Result() {} func (*ApplyResult_ApplyItem_ToolError) isApplyResult_ApplyItem_Result() {} -var File_pluggableharness_plan_v1_plan_proto protoreflect.FileDescriptor +var File_pluggableharness_plan_v1_types_proto protoreflect.FileDescriptor -const file_pluggableharness_plan_v1_plan_proto_rawDesc = "" + +const file_pluggableharness_plan_v1_types_proto_rawDesc = "" + "\n" + - "#pluggableharness/plan/v1/plan.proto\x12\x18pluggableharness.plan.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a'pluggableharness/render/v1/render.proto\x1a#pluggableharness/tool/v1/tool.proto\"\xed\x03\n" + + "$pluggableharness/plan/v1/types.proto\x12\x18pluggableharness.plan.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/render/v1/types.proto\x1a%pluggableharness/tool/v1/errors.proto\x1a$pluggableharness/tool/v1/types.proto\"\xc1\x04\n" + "\bPlanItem\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12 \n" + - "\ftool_call_id\x18\x02 \x01(\tR\n" + - "toolCallId\x12\x1a\n" + - "\bprovider\x18\x03 \x01(\tR\bprovider\x12\x1b\n" + - "\ttool_name\x18\x04 \x01(\tR\btoolName\x12-\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" + + "\acall_id\x18\x02 \x01(\tR\x06callId\x12\x1a\n" + + "\bprovider\x18\x03 \x01(\tR\bprovider\x12%\n" + + "\x0eoperation_name\x18\x04 \x01(\tR\roperationName\x12-\n" + "\x05input\x18\x05 \x01(\v2\x17.google.protobuf.StructR\x05input\x12B\n" + "\bdecision\x18\x06 \x01(\x0e2&.pluggableharness.plan.v1.PlanDecisionR\bdecision\x12\x1d\n" + "\n" + @@ -583,20 +613,20 @@ const file_pluggableharness_plan_v1_plan_proto_rawDesc = "" + "\x04risk\x18\t \x01(\x0e2#.pluggableharness.tool.v1.RiskClassR\x04risk\x12 \n" + "\vdescription\x18\n" + " \x01(\tR\vdescription\x12E\n" + - "\apreview\x18\v \x01(\v2&.pluggableharness.render.v1.RenderTreeH\x00R\apreview\x88\x01\x01B\n" + + "\apreview\x18\v \x01(\v2&.pluggableharness.render.v1.RenderTreeH\x00R\apreview\x88\x01\x01\x12Q\n" + + "\x11producer_category\x18\f \x01(\x0e2$.pluggableharness.common.v1.CategoryR\x10producerCategoryB\n" + "\n" + "\b_preview\"Y\n" + "\x04Plan\x12\x17\n" + "\aturn_id\x18\x01 \x01(\tR\x06turnId\x128\n" + - "\x05items\x18\x02 \x03(\v2\".pluggableharness.plan.v1.PlanItemR\x05items\"\xc0\x04\n" + + "\x05items\x18\x02 \x03(\v2\".pluggableharness.plan.v1.PlanItemR\x05items\"\xb7\x04\n" + "\vApplyResult\x12\x17\n" + "\aturn_id\x18\x01 \x01(\tR\x06turnId\x12E\n" + - "\x05items\x18\x02 \x03(\v2/.pluggableharness.plan.v1.ApplyResult.ApplyItemR\x05items\x1a\xb6\x02\n" + + "\x05items\x18\x02 \x03(\v2/.pluggableharness.plan.v1.ApplyResult.ApplyItemR\x05items\x1a\xad\x02\n" + "\tApplyItem\x12 \n" + "\fplan_item_id\x18\x01 \x01(\tR\n" + - "planItemId\x12 \n" + - "\ftool_call_id\x18\x02 \x01(\tR\n" + - "toolCallId\x12L\n" + + "planItemId\x12\x17\n" + + "\acall_id\x18\x02 \x01(\tR\x06callId\x12L\n" + "\aoutcome\x18\x03 \x01(\x0e22.pluggableharness.plan.v1.ApplyResult.ApplyOutcomeR\aoutcome\x12G\n" + "\vtool_result\x18\x04 \x01(\v2$.pluggableharness.tool.v1.ToolResultH\x00R\n" + "toolResult\x12D\n" + @@ -617,20 +647,20 @@ const file_pluggableharness_plan_v1_plan_proto_rawDesc = "" + "\x12PLAN_DECISION_DENY\x10\x04B google.protobuf.Struct 0, // 1: pluggableharness.plan.v1.PlanItem.decision:type_name -> pluggableharness.plan.v1.PlanDecision 7, // 2: pluggableharness.plan.v1.PlanItem.kind:type_name -> pluggableharness.tool.v1.ToolKind 8, // 3: pluggableharness.plan.v1.PlanItem.risk:type_name -> pluggableharness.tool.v1.RiskClass 9, // 4: pluggableharness.plan.v1.PlanItem.preview:type_name -> pluggableharness.render.v1.RenderTree - 2, // 5: pluggableharness.plan.v1.Plan.items:type_name -> pluggableharness.plan.v1.PlanItem - 5, // 6: pluggableharness.plan.v1.ApplyResult.items:type_name -> pluggableharness.plan.v1.ApplyResult.ApplyItem - 1, // 7: pluggableharness.plan.v1.ApplyResult.ApplyItem.outcome:type_name -> pluggableharness.plan.v1.ApplyResult.ApplyOutcome - 10, // 8: pluggableharness.plan.v1.ApplyResult.ApplyItem.tool_result:type_name -> pluggableharness.tool.v1.ToolResult - 11, // 9: pluggableharness.plan.v1.ApplyResult.ApplyItem.tool_error:type_name -> pluggableharness.tool.v1.ToolError - 10, // [10:10] is the sub-list for method output_type - 10, // [10:10] 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_pluggableharness_plan_v1_plan_proto_init() } -func file_pluggableharness_plan_v1_plan_proto_init() { - if File_pluggableharness_plan_v1_plan_proto != nil { + 10, // 5: pluggableharness.plan.v1.PlanItem.producer_category:type_name -> pluggableharness.common.v1.Category + 2, // 6: pluggableharness.plan.v1.Plan.items:type_name -> pluggableharness.plan.v1.PlanItem + 5, // 7: pluggableharness.plan.v1.ApplyResult.items:type_name -> pluggableharness.plan.v1.ApplyResult.ApplyItem + 1, // 8: pluggableharness.plan.v1.ApplyResult.ApplyItem.outcome:type_name -> pluggableharness.plan.v1.ApplyResult.ApplyOutcome + 11, // 9: pluggableharness.plan.v1.ApplyResult.ApplyItem.tool_result:type_name -> pluggableharness.tool.v1.ToolResult + 12, // 10: pluggableharness.plan.v1.ApplyResult.ApplyItem.tool_error:type_name -> pluggableharness.tool.v1.ToolError + 11, // [11:11] is the sub-list for method output_type + 11, // [11:11] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_pluggableharness_plan_v1_types_proto_init() } +func file_pluggableharness_plan_v1_types_proto_init() { + if File_pluggableharness_plan_v1_types_proto != nil { return } - file_pluggableharness_plan_v1_plan_proto_msgTypes[0].OneofWrappers = []any{} - file_pluggableharness_plan_v1_plan_proto_msgTypes[3].OneofWrappers = []any{ + file_pluggableharness_plan_v1_types_proto_msgTypes[0].OneofWrappers = []any{} + file_pluggableharness_plan_v1_types_proto_msgTypes[3].OneofWrappers = []any{ (*ApplyResult_ApplyItem_ToolResult)(nil), (*ApplyResult_ApplyItem_ToolError)(nil), } @@ -676,18 +708,18 @@ func file_pluggableharness_plan_v1_plan_proto_init() { out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_plan_v1_plan_proto_rawDesc), len(file_pluggableharness_plan_v1_plan_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_plan_v1_types_proto_rawDesc), len(file_pluggableharness_plan_v1_types_proto_rawDesc)), NumEnums: 2, NumMessages: 4, NumExtensions: 0, NumServices: 0, }, - GoTypes: file_pluggableharness_plan_v1_plan_proto_goTypes, - DependencyIndexes: file_pluggableharness_plan_v1_plan_proto_depIdxs, - EnumInfos: file_pluggableharness_plan_v1_plan_proto_enumTypes, - MessageInfos: file_pluggableharness_plan_v1_plan_proto_msgTypes, + GoTypes: file_pluggableharness_plan_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_plan_v1_types_proto_depIdxs, + EnumInfos: file_pluggableharness_plan_v1_types_proto_enumTypes, + MessageInfos: file_pluggableharness_plan_v1_types_proto_msgTypes, }.Build() - File_pluggableharness_plan_v1_plan_proto = out.File - file_pluggableharness_plan_v1_plan_proto_goTypes = nil - file_pluggableharness_plan_v1_plan_proto_depIdxs = nil + File_pluggableharness_plan_v1_types_proto = out.File + file_pluggableharness_plan_v1_types_proto_goTypes = nil + file_pluggableharness_plan_v1_types_proto_depIdxs = nil } diff --git a/pkg/plugin/callback.go b/pkg/plugin/callback.go new file mode 100644 index 0000000..913efc1 --- /dev/null +++ b/pkg/plugin/callback.go @@ -0,0 +1,81 @@ +package plugin + +import ( + "context" + "fmt" + "sync" + + "github.com/hashicorp/go-plugin" + + "github.com/pluggableharness/agent/pkg/kernel" +) + +// Callback is a lazily-dialed handle to the kernel callback channel +// (pkg/kernel.Client). The zero value is not usable; construct one with +// NewCallback. A Callback returned by NewCallback is not yet connected to +// anything — see doc.go's "callback-timing trap" for why the dial must be +// deferred until a plugin author's own RPC handler calls Client, rather +// than happening at construction or from within Serve's internal +// GRPCServer method. Safe for concurrent use. +type Callback struct { + mu sync.Mutex + broker *plugin.GRPCBroker + + once sync.Once + client *kernel.Client + err error + + // dial is kernel.Dial by default; overridden in tests so the + // sync.Once guard can be exercised without a real *plugin.GRPCBroker + // (kernel.Dial's own doc comment explains why one can't be + // constructed from outside hashicorp/go-plugin for a test). + dial func(*plugin.GRPCBroker) (*kernel.Client, error) +} + +// NewCallback returns a Callback not yet connected to anything. Construct +// one and hold onto it in Config.Callback; Serve's internal GRPCPlugin +// adapter records the broker on it once GRPCServer runs. +func NewCallback() *Callback { + return &Callback{dial: kernel.Dial} +} + +// setBroker records broker for a later Client call. Called exactly once +// per plugin process, from within Serve's internal GRPCPlugin adapter's +// GRPCServer method, immediately after go-plugin hands that method a +// broker for this launch (serve.go). +func (c *Callback) setBroker(broker *plugin.GRPCBroker) { + c.mu.Lock() + defer c.mu.Unlock() + c.broker = broker +} + +// Client dials the fixed callback broker ID on first call (sync.Once) and +// returns the resulting *kernel.Client on every call thereafter, including +// a subsequent call after a failed dial (the dial is not retried; a +// failed dial is a permanent condition for the process's lifetime, same +// as a failed handshake would be). Must be called only after go-plugin has +// begun dispensing this process's client to the kernel (i.e. from within a +// Service's own RPC handler, never from inside GRPCServer itself) — see +// doc.go's "callback-timing trap". The context parameter is unused — +// kernel.Dial (and *plugin.GRPCBroker.Dial underneath it) has no +// context-aware variant — and is kept unnamed rather than named ctx so it +// doesn't read as though cancellation is honored; it exists purely so this +// method matches the blocking-call signature convention every other +// method in this SDK follows. +func (c *Callback) Client(context.Context) (*kernel.Client, error) { + c.once.Do(func() { + c.mu.Lock() + broker := c.broker + c.mu.Unlock() + + if broker == nil { + c.err = errCallbackBrokerUnset + return + } + c.client, c.err = c.dial(broker) + }) + if c.err != nil { + return nil, fmt.Errorf("plugin: callback client: %w", c.err) + } + return c.client, nil +} diff --git a/pkg/plugin/callback_internal_test.go b/pkg/plugin/callback_internal_test.go new file mode 100644 index 0000000..c9e3409 --- /dev/null +++ b/pkg/plugin/callback_internal_test.go @@ -0,0 +1,83 @@ +package plugin + +import ( + "errors" + "testing" + + "github.com/hashicorp/go-plugin" + + "github.com/pluggableharness/agent/pkg/kernel" +) + +// This file is a white-box (package plugin, not plugin_test) test +// deliberately: Callback's dial-then-wrap behavior cannot be fully unit +// tested without a real *plugin.GRPCBroker (its only constructor is +// unexported and requires an unexported streamer type this package cannot +// supply from outside hashicorp/go-plugin — the identical, already- +// confirmed limitation pkg/kernel/client.go's Dial doc comment documents +// for its own case). What is tested here — via the unexported dial func +// field — is the sync.Once guard and error-wrapping behavior around +// whatever kernel.Dial would have done, which is exactly the seam +// callback.go's dial field exists to expose for testing. + +func TestCallback_Client_beforeBrokerSet(t *testing.T) { + t.Parallel() + + c := NewCallback() + + _, err := c.Client(t.Context()) + if !errors.Is(err, errCallbackBrokerUnset) { + t.Fatalf("Client() error = %v, want wrapping errCallbackBrokerUnset", err) + } +} + +func TestCallback_Client_dialsOnce(t *testing.T) { + t.Parallel() + + calls := 0 + want := kernel.NewClient(nil) + c := &Callback{ + dial: func(*plugin.GRPCBroker) (*kernel.Client, error) { + calls++ + return want, nil + }, + } + c.setBroker(&plugin.GRPCBroker{}) + + for i := range 3 { + got, err := c.Client(t.Context()) + if err != nil { + t.Fatalf("Client() call %d: error = %v, want nil", i, err) + } + if got != want { + t.Errorf("Client() call %d = %v, want %v", i, got, want) + } + } + if calls != 1 { + t.Errorf("dial called %d times, want 1 (sync.Once)", calls) + } +} + +func TestCallback_Client_dialError(t *testing.T) { + t.Parallel() + + dialErr := errors.New("dial boom") + calls := 0 + c := &Callback{ + dial: func(*plugin.GRPCBroker) (*kernel.Client, error) { + calls++ + return nil, dialErr + }, + } + c.setBroker(&plugin.GRPCBroker{}) + + for i := range 2 { + _, err := c.Client(t.Context()) + if !errors.Is(err, dialErr) { + t.Fatalf("Client() call %d: error = %v, want wrapping %v", i, err, dialErr) + } + } + if calls != 1 { + t.Errorf("dial called %d times, want 1 (sync.Once — a failed dial is not retried)", calls) + } +} diff --git a/pkg/plugin/doc.go b/pkg/plugin/doc.go new file mode 100644 index 0000000..fd66eb1 --- /dev/null +++ b/pkg/plugin/doc.go @@ -0,0 +1,49 @@ +// Package plugin is the shared serving layer every PluggableHarness Agent +// plugin-category SDK (pkg/model, pkg/tool, pkg/context, pkg/memory, +// pkg/frontend, pkg/widget, pkg/slashcommand) is built on to run a plugin +// subprocess. A plugin author's main() constructs a Config — their own +// Identity, the pluggableharness.common.v1.Category they implement, a +// *Callback, and one or more Services — and calls Serve, which blocks for +// the life of the process. +// +// A category SDK's own server.go returns a Service wrapping that +// category's generated ServiceServer; a plugin author passes more than +// one Service to Config.Services to mux additional service surfaces onto +// the same subprocess connection — most commonly hook.v1.HookSubscriberService +// (docs/specifications/agent-loop/hook-dispatch.md) and, for a tool, +// frontend widget, or slashcommand provider that wants a direct-invoke +// shortcut, slashcommand.v1.SlashCommandService +// (docs/specifications/slashcommand/data-types.md). This multi-service +// muxing on one subprocess connection is spec-mandated, not optional — see +// also docs/specifications/tool/protocol.md#getschema and +// docs/specifications/frontend/widget-protocol.md#transport — and is +// exactly what hashicorp/go-plugin's GRPCServer(broker, *grpc.Server) +// hook exists to support: registering more than one gRPC service on the +// single *grpc.Server the subprocess serves +// (.claude/rules/plugin-runtime.md). +// +// # The callback-timing trap +// +// Every plugin subprocess is handed a channel back to +// KernelCallbackService (docs/specifications/kernel-callbacks.md) at a +// fixed, well-known broker ID (pkg/common.CallbackBrokerID) — but the +// kernel does not start serving that channel until it dispenses this +// plugin's client, which happens only after this package's internal +// GRPCServer method has already returned +// (.claude/rules/plugin-runtime.md's "Handshake" section describes the +// same handshake sequence from the kernel side). Dialing the broker +// synchronously inside GRPCServer therefore races, and typically loses, +// against the kernel's own dispense step. +// +// Callback exists specifically to route around this. NewCallback returns +// a handle that is not yet connected to anything; Serve's internal +// plugin.GRPCPlugin adapter records the broker on that handle once +// GRPCServer runs, but does not dial it. The actual broker.Dial only +// happens the first time a plugin author's own RPC handler — running well +// after go-plugin has finished dispensing this process's client back to +// the kernel — calls Callback.Client. Do not "fix" this laziness by +// dialing eagerly inside GRPCServer, a constructor, or any other +// call that can run before this plugin's own gRPC handlers start serving +// traffic; that reintroduces the exact deadlock this design exists to +// avoid. +package plugin diff --git a/pkg/plugin/errors.go b/pkg/plugin/errors.go new file mode 100644 index 0000000..d78e082 --- /dev/null +++ b/pkg/plugin/errors.go @@ -0,0 +1,20 @@ +package plugin + +import "errors" + +// errCallbackBrokerUnset is returned by Callback.Client when called before +// Serve's internal GRPCPlugin adapter has recorded a broker on the +// Callback — i.e. before GRPCServer has run for this plugin process. See +// doc.go's "callback-timing trap" for why Client cannot simply block and +// wait: GRPCServer has to already have returned before the broker it +// hands over is actually servable, so there is no broker to wait for +// until that happens. +var errCallbackBrokerUnset = errors.New("plugin: callback: broker not yet available (Client called before GRPCServer ran)") + +// errGRPCClientUnsupported is returned by grpcPlugin.GRPCClient: this +// package only ever runs plugin-side (serving Config.Services back to the +// kernel), never kernel-side (dialing another process's category +// client) — the plugin-side mirror of +// internal/pluginruntime/adapter.go's errGRPCServerUnsupported. Misuse +// fails loudly, at call time, rather than silently no-op-ing. +var errGRPCClientUnsupported = errors.New("plugin: GRPCClient is not supported plugin-side — this package only serves a plugin process, it never dials one") diff --git a/pkg/plugin/identity.go b/pkg/plugin/identity.go new file mode 100644 index 0000000..e9fa0c4 --- /dev/null +++ b/pkg/plugin/identity.go @@ -0,0 +1,45 @@ +package plugin + +import ( + "github.com/pluggableharness/agent/pkg/common" + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" +) + +// Identity is a plugin build's own self-reported identity, supplied by the +// plugin author rather than read from a lock-file entry. This matters +// specifically for a binary resolved via dev_overrides +// (docs/specifications/configuration/lock-file.md's "dev_overrides and +// identity without a lock entry" section): such a binary has no +// provider "" { ... } row in agent.lock.hcl at all, so the kernel +// has no source/version/checksums to read identity from the way it would +// for a normally-resolved plugin, and instead obtains +// {name, version, source, category, protocol_version} directly from the +// plugin process itself via that category's own Describe RPC. +type Identity struct { + // Name is the plugin's declared name, e.g. "filesystem" — unique + // within the plugin's category, not globally. + Name string + // Version is the plugin's exact version, semver-formatted, e.g. + // "1.2.3". + Version string + // Source is the resolved source address the plugin was installed + // from, e.g. "github.com/agentco/filesystem-provider". + Source string +} + +// ProducerRef builds the common.v1.ProducerRef every category's Describe +// RPC returns, from this identity plus category (the plugin category this +// build is serving) and the protocol version this build was compiled +// against (pkg/common.ProtocolVersion). Category-specific SDKs (pkg/tool, +// pkg/model, ...) call this from their own Describe implementation — this +// package does not implement Describe itself, since DescribeResponse is a +// distinct generated type per category. +func (id Identity) ProducerRef(category commonv1.Category) *commonv1.ProducerRef { + return &commonv1.ProducerRef{ + Name: id.Name, + Version: id.Version, + Source: id.Source, + Category: category, + ProtocolVersion: uint32(common.ProtocolVersion), + } +} diff --git a/pkg/plugin/identity_test.go b/pkg/plugin/identity_test.go new file mode 100644 index 0000000..3235b7b --- /dev/null +++ b/pkg/plugin/identity_test.go @@ -0,0 +1,61 @@ +package plugin_test + +import ( + "testing" + + "github.com/pluggableharness/agent/pkg/common" + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" +) + +func TestIdentity_ProducerRef(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + id plugin.Identity + category commonv1.Category + }{ + { + name: "tool category", + id: plugin.Identity{ + Name: "filesystem", + Version: "1.2.3", + Source: "github.com/agentco/filesystem-provider", + }, + category: commonv1.Category_CATEGORY_TOOL, + }, + { + name: "model category, empty source", + id: plugin.Identity{ + Name: "anthropic", + Version: "0.1.0", + }, + category: commonv1.Category_CATEGORY_MODEL, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ref := tt.id.ProducerRef(tt.category) + + if got, want := ref.GetName(), tt.id.Name; got != want { + t.Errorf("GetName() = %q, want %q", got, want) + } + if got, want := ref.GetVersion(), tt.id.Version; got != want { + t.Errorf("GetVersion() = %q, want %q", got, want) + } + if got, want := ref.GetSource(), tt.id.Source; got != want { + t.Errorf("GetSource() = %q, want %q", got, want) + } + if got, want := ref.GetCategory(), tt.category; got != want { + t.Errorf("GetCategory() = %v, want %v", got, want) + } + if got, want := ref.GetProtocolVersion(), uint32(common.ProtocolVersion); got != want { + t.Errorf("GetProtocolVersion() = %d, want %d", got, want) + } + }) + } +} diff --git a/pkg/plugin/serve.go b/pkg/plugin/serve.go new file mode 100644 index 0000000..f55626d --- /dev/null +++ b/pkg/plugin/serve.go @@ -0,0 +1,102 @@ +package plugin + +import ( + "context" + + "github.com/hashicorp/go-plugin" + "google.golang.org/grpc" + + "github.com/pluggableharness/agent/pkg/common" + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" +) + +// Service is one gRPC service registration a plugin process contributes. +// A category SDK's server.go returns one of these; a plugin author may +// pass more than one Service to Config.Services to mux e.g. ToolService + +// HookSubscriberService on the same connection. +type Service interface { + // Register registers this service's handler on s. + Register(s *grpc.Server) +} + +// Config is what Serve needs to launch one plugin subprocess. +type Config struct { + // Identity is this plugin build's own self-reported identity. + Identity Identity + // Category is this plugin's primary category — the one PluginSet + // entry key. + Category commonv1.Category + // Callback is the lazily-dialed handle to the kernel callback + // channel; the plugin author constructs it via NewCallback and holds + // onto it. + Callback *Callback + // Services is one or more gRPC service registrations, muxed on the + // same *grpc.Server. + Services []Service +} + +// Serve blocks and runs the plugin subprocess main loop — a thin, +// pre-wired call to hashicorp/go-plugin's own plugin.Serve, which does not +// return under normal operation. This is the function a plugin author's +// main() calls. +func Serve(cfg Config) { + plugin.Serve(serveConfig(cfg)) +} + +// serveConfig builds the *plugin.ServeConfig Serve hands to +// hashicorp/go-plugin, factored out of Serve so the handshake and +// PluginSet wiring are unit-testable without invoking the real, +// blocks-forever plugin.Serve. +func serveConfig(cfg Config) *plugin.ServeConfig { + return &plugin.ServeConfig{ + HandshakeConfig: common.Handshake, + Plugins: pluginSet(cfg), + GRPCServer: plugin.DefaultGRPCServer, + } +} + +// pluginSet builds the one-entry go-plugin PluginSet for cfg, keyed by +// common.PluginKey(cfg.Category) — the only entry a single plugin process +// ever serves, since one subprocess implements exactly one primary +// category (though, per Config.Services, it may mux additional service +// surfaces onto that same connection). +func pluginSet(cfg Config) plugin.PluginSet { + return plugin.PluginSet{ + common.PluginKey(cfg.Category): &grpcPlugin{cfg: cfg}, + } +} + +// grpcPlugin is the plugin.GRPCPlugin adapter Serve registers for +// cfg.Category — the plugin-side mirror of +// internal/pluginruntime/adapter.go's categoryPlugin (which runs +// kernel-side). GRPCServer registers every cfg.Services entry on the +// shared *grpc.Server and records broker on cfg.Callback for later lazy +// dialing (doc.go's "callback-timing trap"); GRPCClient always errors, +// since this package only ever runs plugin-side. +type grpcPlugin struct { + plugin.Plugin + + cfg Config +} + +var _ plugin.GRPCPlugin = (*grpcPlugin)(nil) + +// GRPCServer registers every p.cfg.Services entry on s, then — if +// p.cfg.Callback is set — records broker on it so a later Callback.Client +// call can lazily dial the fixed callback broker ID once go-plugin +// actually begins serving it, which happens only after this method +// returns (doc.go). +func (p *grpcPlugin) GRPCServer(broker *plugin.GRPCBroker, s *grpc.Server) error { + for _, svc := range p.cfg.Services { + svc.Register(s) + } + if p.cfg.Callback != nil { + p.cfg.Callback.setBroker(broker) + } + return nil +} + +// GRPCClient always fails: see errGRPCClientUnsupported. +func (p *grpcPlugin) GRPCClient(context.Context, *plugin.GRPCBroker, *grpc.ClientConn) (any, error) { + return nil, errGRPCClientUnsupported +} diff --git a/pkg/plugin/serve_internal_test.go b/pkg/plugin/serve_internal_test.go new file mode 100644 index 0000000..abbc38c --- /dev/null +++ b/pkg/plugin/serve_internal_test.go @@ -0,0 +1,126 @@ +package plugin + +import ( + "errors" + "testing" + + "github.com/hashicorp/go-plugin" + "google.golang.org/grpc" + + "github.com/pluggableharness/agent/pkg/common" + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" +) + +// This file is a white-box (package plugin, not plugin_test) test, +// deliberately, for the same reason callback_internal_test.go is: it needs +// grpcPlugin and pluginSet/serveConfig, which are unexported by design +// (Serve itself blocks forever in real operation and is not something a +// unit test can call directly — only its constituent, non-blocking pieces +// are testable here, following the same split-for-testability reasoning +// internal/pluginruntime/launch.go's buildClient uses). + +// fakeService records the *grpc.Server it was registered on, for +// asserting grpcPlugin.GRPCServer actually plumbed it through. +type fakeService struct { + registered *grpc.Server +} + +func (f *fakeService) Register(s *grpc.Server) { + f.registered = s +} + +func TestGRPCPlugin_GRPCServer_registersServicesAndBroker(t *testing.T) { + t.Parallel() + + svc1 := &fakeService{} + svc2 := &fakeService{} + cb := NewCallback() + p := &grpcPlugin{cfg: Config{ + Identity: Identity{Name: "fixture"}, + Category: commonv1.Category_CATEGORY_TOOL, + Callback: cb, + Services: []Service{svc1, svc2}, + }} + + s := grpc.NewServer() + broker := &plugin.GRPCBroker{} + + if err := p.GRPCServer(broker, s); err != nil { + t.Fatalf("GRPCServer() error = %v, want nil", err) + } + + if svc1.registered != s { + t.Errorf("svc1.registered = %v, want %v", svc1.registered, s) + } + if svc2.registered != s { + t.Errorf("svc2.registered = %v, want %v", svc2.registered, s) + } + + cb.mu.Lock() + gotBroker := cb.broker + cb.mu.Unlock() + if gotBroker != broker { + t.Errorf("Callback.broker = %v, want %v", gotBroker, broker) + } +} + +func TestGRPCPlugin_GRPCServer_nilCallback(t *testing.T) { + t.Parallel() + + p := &grpcPlugin{cfg: Config{Services: []Service{&fakeService{}}}} + + if err := p.GRPCServer(&plugin.GRPCBroker{}, grpc.NewServer()); err != nil { + t.Fatalf("GRPCServer() error = %v, want nil", err) + } +} + +func TestGRPCPlugin_GRPCClient(t *testing.T) { + t.Parallel() + + p := &grpcPlugin{} + + _, err := p.GRPCClient(t.Context(), nil, nil) + if !errors.Is(err, errGRPCClientUnsupported) { + t.Fatalf("GRPCClient() error = %v, want errGRPCClientUnsupported", err) + } +} + +func TestPluginSet(t *testing.T) { + t.Parallel() + + cfg := Config{Category: commonv1.Category_CATEGORY_MODEL} + set := pluginSet(cfg) + + if got, want := len(set), 1; got != want { + t.Fatalf("len(pluginSet(cfg)) = %d, want %d", got, want) + } + key := common.PluginKey(cfg.Category) + if _, ok := set[key]; !ok { + t.Errorf("pluginSet(cfg) missing key %q, got keys %v", key, mapKeys(set)) + } +} + +func TestServeConfig(t *testing.T) { + t.Parallel() + + cfg := Config{Category: commonv1.Category_CATEGORY_MODEL} + sc := serveConfig(cfg) + + if sc.HandshakeConfig != common.Handshake { + t.Errorf("HandshakeConfig = %+v, want %+v", sc.HandshakeConfig, common.Handshake) + } + if sc.GRPCServer == nil { + t.Error("GRPCServer is nil, want plugin.DefaultGRPCServer") + } + if got, want := len(sc.Plugins), 1; got != want { + t.Errorf("len(Plugins) = %d, want %d", got, want) + } +} + +func mapKeys(m plugin.PluginSet) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/pkg/plugin/status.go b/pkg/plugin/status.go new file mode 100644 index 0000000..b54a120 --- /dev/null +++ b/pkg/plugin/status.go @@ -0,0 +1,34 @@ +package plugin + +import ( + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// StatusError builds a *status.Status-backed error with code and a +// google.rpc.ErrorInfo structured detail (reason, domain, metadata) — the +// canonical "most specific code, never bare codes.Unknown, category enum +// in structured detail" shape .claude/rules/grpc.md mandates for every RPC +// error crossing the plugin boundary. domain should be the calling +// category's own error-taxonomy name, e.g. "tool.pluggableharness.dev". +// metadata MAY be nil when there is no additional structured detail to +// attach. +func StatusError(code codes.Code, domain, reason, message string, metadata map[string]string) error { + st := status.New(code, message) + + withDetails, err := st.WithDetails(&errdetails.ErrorInfo{ + Reason: reason, + Domain: domain, + Metadata: metadata, + }) + if err != nil { + // WithDetails only fails if a detail message can't be marshaled + // into an Any, which cannot happen for a well-formed + // *errdetails.ErrorInfo built from plain strings — fall back to + // the detail-less status rather than losing the code and + // message a caller already has in hand. + return st.Err() + } + return withDetails.Err() +} diff --git a/pkg/plugin/status_test.go b/pkg/plugin/status_test.go new file mode 100644 index 0000000..f7e39cc --- /dev/null +++ b/pkg/plugin/status_test.go @@ -0,0 +1,88 @@ +package plugin_test + +import ( + "testing" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/pluggableharness/agent/pkg/plugin" +) + +func TestStatusError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + code codes.Code + domain string + reason string + message string + metadata map[string]string + }{ + { + name: "resource exhausted with metadata", + code: codes.ResourceExhausted, + domain: "tool.pluggableharness.dev", + reason: "CONTEXT_LENGTH_EXCEEDED", + message: "context length exceeded", + metadata: map[string]string{"limit": "8192"}, + }, + { + name: "internal, no metadata", + code: codes.Internal, + domain: "model.pluggableharness.dev", + reason: "UNEXPECTED", + message: "unexpected failure", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := plugin.StatusError(tt.code, tt.domain, tt.reason, tt.message, tt.metadata) + if err == nil { + t.Fatalf("StatusError(%v, %q, %q, %q, %v) = nil, want non-nil error", tt.code, tt.domain, tt.reason, tt.message, tt.metadata) + } + + st, ok := status.FromError(err) + if !ok { + t.Fatalf("status.FromError(%v) ok = false, want true", err) + } + if got := st.Code(); got != tt.code { + t.Errorf("Code() = %v, want %v", got, tt.code) + } + if got := st.Message(); got != tt.message { + t.Errorf("Message() = %q, want %q", got, tt.message) + } + + var found *errdetails.ErrorInfo + for _, d := range st.Details() { + info, ok := d.(*errdetails.ErrorInfo) + if ok { + found = info + break + } + } + if found == nil { + t.Fatalf("Details() contains no *errdetails.ErrorInfo, got %v", st.Details()) + } + if got, want := found.GetReason(), tt.reason; got != want { + t.Errorf("ErrorInfo.Reason = %q, want %q", got, want) + } + if got, want := found.GetDomain(), tt.domain; got != want { + t.Errorf("ErrorInfo.Domain = %q, want %q", got, want) + } + if got, want := len(found.GetMetadata()), len(tt.metadata); got != want { + t.Errorf("len(ErrorInfo.Metadata) = %d, want %d", got, want) + } + for k, want := range tt.metadata { + if got := found.GetMetadata()[k]; got != want { + t.Errorf("ErrorInfo.Metadata[%q] = %q, want %q", k, got, want) + } + } + }) + } +} diff --git a/pkg/render/doc.go b/pkg/render/doc.go new file mode 100644 index 0000000..3301676 --- /dev/null +++ b/pkg/render/doc.go @@ -0,0 +1,52 @@ +// Package render is the plugin-author-facing builder layer over the +// generated pluggableharness.render.v1 message types +// (pkg/render/proto/v1), the wire shape defined in +// docs/specifications/frontend/render-tree.md. +// +// # Emit -> Render -> Paint +// +// A plugin category (model, tool, context, memory — and their frontend/ +// widget consumers) that wants to show something beyond plain text +// implements the Emit->Render->Paint pipeline: the plugin Emits an opaque +// payload at the point an event happens +// (docs/specifications/kernel-callbacks.md#emit), the kernel later calls +// that category's optional Render RPC to turn the stored payload into a +// display-agnostic pluggableharness.render.v1.RenderTree +// (docs/specifications/frontend/render-tree.md#rendertree), and the +// kernel's active frontend Paints that tree without ever knowing the +// payload's original shape. This package is for the middle stage: it +// gives a Render implementation a fluent way to build the RenderTree/ +// RenderNode values that stage returns, instead of hand-assembling the +// generated types' nested oneof wrappers by hand. +// +// # What this package is not +// +// Per .claude/rules/go-layout.md's "exactly one Go representation of each +// wire message" rule, this package does not define a second, parallel Go +// type for RenderNode or RenderTree. Every builder function here returns +// the generated *renderv1.RenderNode or *renderv1.RenderTree directly — +// this is a set of pure constructor functions over the generated types, +// not a domain model that gets converted to them. +// +// # Node types +// +// One builder per node type +// (docs/specifications/frontend/render-tree.md#node-types): Text/ +// TextStyled, Code, Diff (plus Hunk and the DiffLine* helpers), Table, +// Link, List, Group, Collapsible, SubSession, and Action. Group, List, and +// Collapsible are the three recursive node types — see +// docs/specifications/frontend/render-tree.md#rendertree ("Recursion +// happens via ListNode.items, GroupNode.children, and +// CollapsibleNode.children"). +// +// # Schema versioning +// +// A plugin's Render implementation MUST branch on the RenderRequest's +// schema_version rather than sniffing the payload's shape, and MUST keep +// decoding every schema_version it has ever emitted +// (docs/specifications/frontend/render-tree.md#schema-versioning). +// VersionRegistry in version.go is a small dispatch helper that makes +// that the path of least resistance: register one VersionedRenderer per +// schema_version a plugin has ever shipped, then call Render with the +// schema_version threaded through from the RenderRequest. +package render diff --git a/pkg/render/errors.go b/pkg/render/errors.go new file mode 100644 index 0000000..e8f90b5 --- /dev/null +++ b/pkg/render/errors.go @@ -0,0 +1,14 @@ +package render + +import "errors" + +// ErrUnknownSchemaVersion is returned by (*VersionRegistry).Render when no +// VersionedRenderer has been registered for the requested schema_version. +// docs/specifications/frontend/render-tree.md#schema-versioning requires a +// plugin's Render implementation to keep decoding every schema_version it +// has ever emitted; an unregistered version reaching Render means either a +// plugin build regressed (it forgot to keep an old decoder registered) or +// the caller passed a version this plugin never emitted, and either way +// the caller needs a distinguishable error rather than a generic decode +// failure. +var ErrUnknownSchemaVersion = errors.New("render: unknown schema version") diff --git a/pkg/render/nodes.go b/pkg/render/nodes.go new file mode 100644 index 0000000..6bd538c --- /dev/null +++ b/pkg/render/nodes.go @@ -0,0 +1,204 @@ +package render + +import ( + structpb "google.golang.org/protobuf/types/known/structpb" + + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// Text builds a plain TextNode with no style hint — the frontend applies +// its own default styling +// (docs/specifications/frontend/render-tree.md#node-types: "style unset +// means 'frontend's own default'"). Use TextStyled to request a specific +// TextStyle, including TEXT_STYLE_NORMAL's "explicitly plain" state. +func Text(content string) *renderv1.RenderNode { + return &renderv1.RenderNode{ + Node: &renderv1.RenderNode_Text{ + Text: &renderv1.TextNode{Content: content}, + }, + } +} + +// TextStyled builds a TextNode with an explicit presentation hint. Passing +// renderv1.TextStyle_TEXT_STYLE_NORMAL is a producer deliberately +// requesting plain styling — a distinct wire state from the unset style +// Text produces. +func TextStyled(content string, style renderv1.TextStyle) *renderv1.RenderNode { + return &renderv1.RenderNode{ + Node: &renderv1.RenderNode_Text{ + Text: &renderv1.TextNode{Content: content, Style: style.Enum()}, + }, + } +} + +// Code builds a CodeBlockNode. An empty language means no syntax +// highlighting (docs/specifications/frontend/render-tree.md#node-types: +// "language unset means no syntax highlighting") — Code treats the empty +// string as that unset state rather than requiring callers to pass a nil +// pointer. +func Code(language, content string) *renderv1.RenderNode { + block := &renderv1.CodeBlockNode{Content: content} + if language != "" { + block.Language = &language + } + return &renderv1.RenderNode{ + Node: &renderv1.RenderNode_CodeBlock{CodeBlock: block}, + } +} + +// Diff builds a DiffNode from a set of unified-diff-shaped hunks, in file +// order. Use Hunk to build each *renderv1.DiffHunk and DiffContextLine/ +// DiffAddLine/DiffRemoveLine to build its lines. +func Diff(hunks ...*renderv1.DiffHunk) *renderv1.RenderNode { + return &renderv1.RenderNode{ + Node: &renderv1.RenderNode_Diff{ + Diff: &renderv1.DiffNode{Hunks: hunks}, + }, + } +} + +// Hunk builds one DiffHunk, mirroring a standard unified-diff hunk header +// (`@@ -old_start,old_lines +new_start,new_lines @@`) plus its lines. +func Hunk(oldStart, oldLines, newStart, newLines int32, lines ...*renderv1.DiffLine) *renderv1.DiffHunk { + return &renderv1.DiffHunk{ + OldStart: oldStart, + OldLines: oldLines, + NewStart: newStart, + NewLines: newLines, + Lines: lines, + } +} + +// DiffContextLine builds an unchanged-context DiffLine, present in both +// the old and new versions. +func DiffContextLine(text string) *renderv1.DiffLine { + return &renderv1.DiffLine{Op: renderv1.DiffLineOp_DIFF_LINE_OP_CONTEXT, Text: text} +} + +// DiffAddLine builds a DiffLine for a line added in the new version. +func DiffAddLine(text string) *renderv1.DiffLine { + return &renderv1.DiffLine{Op: renderv1.DiffLineOp_DIFF_LINE_OP_ADD, Text: text} +} + +// DiffRemoveLine builds a DiffLine for a line removed from the old +// version. +func DiffRemoveLine(text string) *renderv1.DiffLine { + return &renderv1.DiffLine{Op: renderv1.DiffLineOp_DIFF_LINE_OP_REMOVE, Text: text} +} + +// Table builds a TableNode from column headers and rows of string cells. +// Table is deliberately flat — string cells only, no nested RenderNode per +// cell (docs/specifications/frontend/render-tree.md#node-types) — each +// row's cells are expected to align to headers by index, but Table does +// not itself validate row width against len(headers); a mismatched row is +// a producer bug the frontend renders as-is rather than one this builder +// silently pads or truncates. +func Table(headers []string, rows [][]string) *renderv1.RenderNode { + tableRows := make([]*renderv1.TableRow, len(rows)) + for i, row := range rows { + tableRows[i] = &renderv1.TableRow{Cells: row} + } + return &renderv1.RenderNode{ + Node: &renderv1.RenderNode_Table{ + Table: &renderv1.TableNode{Headers: headers, Rows: tableRows}, + }, + } +} + +// Link builds a LinkNode — a hyperlink with display text and a target +// URL, in that field order to match LinkNode.Text/LinkNode.Url. +func Link(text, url string) *renderv1.RenderNode { + return &renderv1.RenderNode{ + Node: &renderv1.RenderNode_Link{ + Link: &renderv1.LinkNode{Text: text, Url: url}, + }, + } +} + +// List builds an unordered (bulleted) ListNode from its items, in display +// order. Use OrderedList for a numbered list. +func List(items ...*renderv1.RenderNode) *renderv1.RenderNode { + return &renderv1.RenderNode{ + Node: &renderv1.RenderNode_List{ + List: &renderv1.ListNode{Items: items, Ordered: false}, + }, + } +} + +// OrderedList builds a numbered ListNode from its items, in display +// order. Use List for a bulleted (unordered) list. +func OrderedList(items ...*renderv1.RenderNode) *renderv1.RenderNode { + return &renderv1.RenderNode{ + Node: &renderv1.RenderNode_List{ + List: &renderv1.ListNode{Items: items, Ordered: true}, + }, + } +} + +// Group builds a GroupNode — a plain, transparent container with no +// implied wrapper (border, indentation, label) beyond what the frontend +// chooses to apply. +func Group(children ...*renderv1.RenderNode) *renderv1.RenderNode { + return &renderv1.RenderNode{ + Node: &renderv1.RenderNode_Group{ + Group: &renderv1.GroupNode{Children: children}, + }, + } +} + +// Collapsible builds a CollapsibleNode that starts expanded. Use +// CollapsedByDefault for one that starts collapsed. +func Collapsible(summary string, children ...*renderv1.RenderNode) *renderv1.RenderNode { + return &renderv1.RenderNode{ + Node: &renderv1.RenderNode_Collapsible{ + Collapsible: &renderv1.CollapsibleNode{Summary: summary, Children: children}, + }, + } +} + +// CollapsedByDefault builds a CollapsibleNode that starts collapsed. Use +// Collapsible for one that starts expanded. +func CollapsedByDefault(summary string, children ...*renderv1.RenderNode) *renderv1.RenderNode { + return &renderv1.RenderNode{ + Node: &renderv1.RenderNode_Collapsible{ + Collapsible: &renderv1.CollapsibleNode{ + Summary: summary, + Children: children, + CollapsedByDefault: true, + }, + }, + } +} + +// SubSession builds a SubSessionNode — a pointer to a nested agent +// transcript (e.g. a RunSession-spawned child) rather than an inline copy +// of its content. See docs/specifications/kernel-callbacks.md and +// docs/specifications/agent-loop/subagents.md. +func SubSession(sessionID, summary string) *renderv1.RenderNode { + return &renderv1.RenderNode{ + Node: &renderv1.RenderNode_SubSession{ + SubSession: &renderv1.SubSessionNode{SessionId: sessionID, Summary: summary}, + }, + } +} + +// Action builds an ActionNode — interactive/clickable content. Per +// docs/specifications/frontend/render-tree.md#interactive-content-the-action-node, +// a frontend rendering this node MUST dispatch a ClientEvent.action_trigger +// carrying toolName/args/provider unchanged on activation, so Action +// carries exactly the fields that trigger needs alongside id (for +// correlating the resulting event back to this node) and label (the +// clickable text shown to the user). +func Action(id, label, toolName string, args *structpb.Struct, provider string) *renderv1.RenderNode { + return &renderv1.RenderNode{ + Node: &renderv1.RenderNode_Action{ + Action: &renderv1.ActionNode{ + Id: id, + Label: label, + ToolName: toolName, + Args: args, + Provider: provider, + }, + }, + } +} diff --git a/pkg/render/nodes_test.go b/pkg/render/nodes_test.go new file mode 100644 index 0000000..01c847e --- /dev/null +++ b/pkg/render/nodes_test.go @@ -0,0 +1,297 @@ +package render_test + +import ( + "testing" + + structpb "google.golang.org/protobuf/types/known/structpb" + + "github.com/pluggableharness/agent/pkg/render" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +func TestText(t *testing.T) { + t.Parallel() + + got := render.Text("hello") + text, ok := got.GetNode().(*renderv1.RenderNode_Text) + if !ok { + t.Fatalf("Text(%q).GetNode() type = %T, want *renderv1.RenderNode_Text", "hello", got.GetNode()) + } + if text.Text.GetContent() != "hello" { + t.Errorf("Text(%q).Content = %q, want %q", "hello", text.Text.GetContent(), "hello") + } + if text.Text.Style != nil { + t.Errorf("Text(%q).Style = %v, want nil (unset)", "hello", text.Text.Style) + } +} + +func TestTextStyled(t *testing.T) { + t.Parallel() + + got := render.TextStyled("careful", renderv1.TextStyle_TEXT_STYLE_WARNING) + text := got.GetText() + if text == nil { + t.Fatalf("TextStyled(...).GetText() = nil, want set") + } + if text.GetContent() != "careful" { + t.Errorf("TextStyled content = %q, want %q", text.GetContent(), "careful") + } + if text.GetStyle() != renderv1.TextStyle_TEXT_STYLE_WARNING { + t.Errorf("TextStyled style = %v, want %v", text.GetStyle(), renderv1.TextStyle_TEXT_STYLE_WARNING) + } +} + +func TestCode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + language string + content string + wantLang string + wantSet bool + }{ + {name: "with language", language: "go", content: "package main", wantLang: "go", wantSet: true}, + {name: "no language", language: "", content: "plain text", wantSet: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := render.Code(tt.language, tt.content) + block := got.GetCodeBlock() + if block == nil { + t.Fatalf("Code(%q, %q).GetCodeBlock() = nil, want set", tt.language, tt.content) + } + if block.GetContent() != tt.content { + t.Errorf("Code content = %q, want %q", block.GetContent(), tt.content) + } + if tt.wantSet && (block.Language == nil || *block.Language != tt.wantLang) { + t.Errorf("Code language = %v, want %q", block.Language, tt.wantLang) + } + if !tt.wantSet && block.Language != nil { + t.Errorf("Code language = %q, want nil (unset)", *block.Language) + } + }) + } +} + +func TestDiff(t *testing.T) { + t.Parallel() + + hunk := render.Hunk(1, 2, 1, 3, + render.DiffContextLine("unchanged"), + render.DiffAddLine("new line"), + render.DiffRemoveLine("old line"), + ) + got := render.Diff(hunk) + diff := got.GetDiff() + if diff == nil { + t.Fatalf("Diff(...).GetDiff() = nil, want set") + } + if len(diff.GetHunks()) != 1 { + t.Fatalf("Diff(...).Hunks has %d entries, want 1", len(diff.GetHunks())) + } + h := diff.GetHunks()[0] + if h.GetOldStart() != 1 || h.GetOldLines() != 2 || h.GetNewStart() != 1 || h.GetNewLines() != 3 { + t.Errorf("Hunk header = (%d,%d,%d,%d), want (1,2,1,3)", h.GetOldStart(), h.GetOldLines(), h.GetNewStart(), h.GetNewLines()) + } + if len(h.GetLines()) != 3 { + t.Fatalf("Hunk has %d lines, want 3", len(h.GetLines())) + } + + wantOps := []renderv1.DiffLineOp{ + renderv1.DiffLineOp_DIFF_LINE_OP_CONTEXT, + renderv1.DiffLineOp_DIFF_LINE_OP_ADD, + renderv1.DiffLineOp_DIFF_LINE_OP_REMOVE, + } + wantText := []string{"unchanged", "new line", "old line"} + for i, line := range h.GetLines() { + if line.GetOp() != wantOps[i] { + t.Errorf("line[%d].Op = %v, want %v", i, line.GetOp(), wantOps[i]) + } + if line.GetText() != wantText[i] { + t.Errorf("line[%d].Text = %q, want %q", i, line.GetText(), wantText[i]) + } + } +} + +func TestDiffEmpty(t *testing.T) { + t.Parallel() + + got := render.Diff() + diff := got.GetDiff() + if diff == nil { + t.Fatalf("Diff().GetDiff() = nil, want set") + } + if len(diff.GetHunks()) != 0 { + t.Errorf("Diff().Hunks has %d entries, want 0", len(diff.GetHunks())) + } +} + +func TestTable(t *testing.T) { + t.Parallel() + + headers := []string{"name", "value"} + rows := [][]string{ + {"a", "1"}, + {"b", "2"}, + } + got := render.Table(headers, rows) + table := got.GetTable() + if table == nil { + t.Fatalf("Table(...).GetTable() = nil, want set") + } + if len(table.GetHeaders()) != 2 || table.GetHeaders()[0] != "name" || table.GetHeaders()[1] != "value" { + t.Errorf("Table headers = %v, want %v", table.GetHeaders(), headers) + } + if len(table.GetRows()) != 2 { + t.Fatalf("Table has %d rows, want 2", len(table.GetRows())) + } + if got, want := table.GetRows()[0].GetCells(), rows[0]; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Errorf("Table row[0] = %v, want %v", got, want) + } + if got, want := table.GetRows()[1].GetCells(), rows[1]; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Errorf("Table row[1] = %v, want %v", got, want) + } +} + +func TestLink(t *testing.T) { + t.Parallel() + + got := render.Link("click me", "https://example.com") + link := got.GetLink() + if link == nil { + t.Fatalf("Link(...).GetLink() = nil, want set") + } + if link.GetText() != "click me" { + t.Errorf("Link text = %q, want %q", link.GetText(), "click me") + } + if link.GetUrl() != "https://example.com" { + t.Errorf("Link url = %q, want %q", link.GetUrl(), "https://example.com") + } +} + +func TestList(t *testing.T) { + t.Parallel() + + item1, item2 := render.Text("one"), render.Text("two") + got := render.List(item1, item2) + list := got.GetList() + if list == nil { + t.Fatalf("List(...).GetList() = nil, want set") + } + if list.GetOrdered() { + t.Errorf("List(...).Ordered = true, want false") + } + if len(list.GetItems()) != 2 { + t.Fatalf("List(...).Items has %d entries, want 2", len(list.GetItems())) + } +} + +func TestOrderedList(t *testing.T) { + t.Parallel() + + got := render.OrderedList(render.Text("first")) + list := got.GetList() + if list == nil { + t.Fatalf("OrderedList(...).GetList() = nil, want set") + } + if !list.GetOrdered() { + t.Errorf("OrderedList(...).Ordered = false, want true") + } + if len(list.GetItems()) != 1 { + t.Fatalf("OrderedList(...).Items has %d entries, want 1", len(list.GetItems())) + } +} + +func TestGroup(t *testing.T) { + t.Parallel() + + got := render.Group(render.Text("a"), render.Text("b")) + group := got.GetGroup() + if group == nil { + t.Fatalf("Group(...).GetGroup() = nil, want set") + } + if len(group.GetChildren()) != 2 { + t.Fatalf("Group(...).Children has %d entries, want 2", len(group.GetChildren())) + } +} + +func TestCollapsible(t *testing.T) { + t.Parallel() + + got := render.Collapsible("details", render.Text("body")) + c := got.GetCollapsible() + if c == nil { + t.Fatalf("Collapsible(...).GetCollapsible() = nil, want set") + } + if c.GetSummary() != "details" { + t.Errorf("Collapsible summary = %q, want %q", c.GetSummary(), "details") + } + if c.GetCollapsedByDefault() { + t.Errorf("Collapsible(...).CollapsedByDefault = true, want false") + } + if len(c.GetChildren()) != 1 { + t.Fatalf("Collapsible(...).Children has %d entries, want 1", len(c.GetChildren())) + } +} + +func TestCollapsedByDefault(t *testing.T) { + t.Parallel() + + got := render.CollapsedByDefault("details", render.Text("body")) + c := got.GetCollapsible() + if c == nil { + t.Fatalf("CollapsedByDefault(...).GetCollapsible() = nil, want set") + } + if !c.GetCollapsedByDefault() { + t.Errorf("CollapsedByDefault(...).CollapsedByDefault = false, want true") + } +} + +func TestSubSession(t *testing.T) { + t.Parallel() + + got := render.SubSession("session-01ARZ3", "did a thing") + sub := got.GetSubSession() + if sub == nil { + t.Fatalf("SubSession(...).GetSubSession() = nil, want set") + } + if sub.GetSessionId() != "session-01ARZ3" { + t.Errorf("SubSession sessionID = %q, want %q", sub.GetSessionId(), "session-01ARZ3") + } + if sub.GetSummary() != "did a thing" { + t.Errorf("SubSession summary = %q, want %q", sub.GetSummary(), "did a thing") + } +} + +func TestAction(t *testing.T) { + t.Parallel() + + args, err := structpb.NewStruct(map[string]any{"path": "/tmp/x"}) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + got := render.Action("action-1", "Undo", "undo_edit", args, "ripgrep") + action := got.GetAction() + if action == nil { + t.Fatalf("Action(...).GetAction() = nil, want set") + } + if action.GetId() != "action-1" { + t.Errorf("Action id = %q, want %q", action.GetId(), "action-1") + } + if action.GetLabel() != "Undo" { + t.Errorf("Action label = %q, want %q", action.GetLabel(), "Undo") + } + if action.GetToolName() != "undo_edit" { + t.Errorf("Action toolName = %q, want %q", action.GetToolName(), "undo_edit") + } + if action.GetProvider() != "ripgrep" { + t.Errorf("Action provider = %q, want %q", action.GetProvider(), "ripgrep") + } + if action.GetArgs().GetFields()["path"].GetStringValue() != "/tmp/x" { + t.Errorf("Action args[path] = %q, want %q", action.GetArgs().GetFields()["path"].GetStringValue(), "/tmp/x") + } +} diff --git a/pkg/render/proto/v1/render.pb.go b/pkg/render/proto/v1/types.pb.go similarity index 87% rename from pkg/render/proto/v1/render.pb.go rename to pkg/render/proto/v1/types.pb.go index 15e7e76..3ad3f52 100644 --- a/pkg/render/proto/v1/render.pb.go +++ b/pkg/render/proto/v1/types.pb.go @@ -2,7 +2,7 @@ // versions: // protoc-gen-go v1.36.11 // protoc (unknown) -// source: pluggableharness/render/v1/render.proto +// source: pluggableharness/render/v1/types.proto // Package pluggableharness.render.v1 defines the Emit->Render->Paint intermediate // representation described in specifications/frontend.md §1. Every plugin @@ -93,11 +93,11 @@ func (x Region) String() string { } func (Region) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_render_v1_render_proto_enumTypes[0].Descriptor() + return file_pluggableharness_render_v1_types_proto_enumTypes[0].Descriptor() } func (Region) Type() protoreflect.EnumType { - return &file_pluggableharness_render_v1_render_proto_enumTypes[0] + return &file_pluggableharness_render_v1_types_proto_enumTypes[0] } func (x Region) Number() protoreflect.EnumNumber { @@ -106,7 +106,7 @@ func (x Region) Number() protoreflect.EnumNumber { // Deprecated: Use Region.Descriptor instead. func (Region) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{0} } // TextStyle is a hint for how a TextNode's content should be presented. @@ -166,11 +166,11 @@ func (x TextStyle) String() string { } func (TextStyle) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_render_v1_render_proto_enumTypes[1].Descriptor() + return file_pluggableharness_render_v1_types_proto_enumTypes[1].Descriptor() } func (TextStyle) Type() protoreflect.EnumType { - return &file_pluggableharness_render_v1_render_proto_enumTypes[1] + return &file_pluggableharness_render_v1_types_proto_enumTypes[1] } func (x TextStyle) Number() protoreflect.EnumNumber { @@ -179,7 +179,7 @@ func (x TextStyle) Number() protoreflect.EnumNumber { // Deprecated: Use TextStyle.Descriptor instead. func (TextStyle) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{1} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{1} } // DiffLineOp classifies one line of a DiffHunk, mirroring unified diff's @@ -225,11 +225,11 @@ func (x DiffLineOp) String() string { } func (DiffLineOp) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_render_v1_render_proto_enumTypes[2].Descriptor() + return file_pluggableharness_render_v1_types_proto_enumTypes[2].Descriptor() } func (DiffLineOp) Type() protoreflect.EnumType { - return &file_pluggableharness_render_v1_render_proto_enumTypes[2] + return &file_pluggableharness_render_v1_types_proto_enumTypes[2] } func (x DiffLineOp) Number() protoreflect.EnumNumber { @@ -238,7 +238,7 @@ func (x DiffLineOp) Number() protoreflect.EnumNumber { // Deprecated: Use DiffLineOp.Descriptor instead. func (DiffLineOp) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{2} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{2} } // RenderTree is the return type of every category's Render() RPC. @@ -257,7 +257,7 @@ type RenderTree struct { func (x *RenderTree) Reset() { *x = RenderTree{} - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[0] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -269,7 +269,7 @@ func (x *RenderTree) String() string { func (*RenderTree) ProtoMessage() {} func (x *RenderTree) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[0] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -282,7 +282,7 @@ func (x *RenderTree) ProtoReflect() protoreflect.Message { // Deprecated: Use RenderTree.ProtoReflect.Descriptor instead. func (*RenderTree) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{0} } func (x *RenderTree) GetRoot() *RenderNode { @@ -313,7 +313,7 @@ type PlacedContent struct { func (x *PlacedContent) Reset() { *x = PlacedContent{} - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[1] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -325,7 +325,7 @@ func (x *PlacedContent) String() string { func (*PlacedContent) ProtoMessage() {} func (x *PlacedContent) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[1] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -338,7 +338,7 @@ func (x *PlacedContent) ProtoReflect() protoreflect.Message { // Deprecated: Use PlacedContent.ProtoReflect.Descriptor instead. func (*PlacedContent) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{1} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{1} } func (x *PlacedContent) GetRegion() Region { @@ -382,7 +382,7 @@ type DiffLine struct { func (x *DiffLine) Reset() { *x = DiffLine{} - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[2] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -394,7 +394,7 @@ func (x *DiffLine) String() string { func (*DiffLine) ProtoMessage() {} func (x *DiffLine) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[2] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -407,7 +407,7 @@ func (x *DiffLine) ProtoReflect() protoreflect.Message { // Deprecated: Use DiffLine.ProtoReflect.Descriptor instead. func (*DiffLine) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{2} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{2} } func (x *DiffLine) GetOp() DiffLineOp { @@ -447,7 +447,7 @@ type DiffHunk struct { func (x *DiffHunk) Reset() { *x = DiffHunk{} - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[3] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -459,7 +459,7 @@ func (x *DiffHunk) String() string { func (*DiffHunk) ProtoMessage() {} func (x *DiffHunk) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[3] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -472,7 +472,7 @@ func (x *DiffHunk) ProtoReflect() protoreflect.Message { // Deprecated: Use DiffHunk.ProtoReflect.Descriptor instead. func (*DiffHunk) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{3} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{3} } func (x *DiffHunk) GetOldStart() int32 { @@ -534,7 +534,7 @@ type RenderNode struct { func (x *RenderNode) Reset() { *x = RenderNode{} - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[4] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -546,7 +546,7 @@ func (x *RenderNode) String() string { func (*RenderNode) ProtoMessage() {} func (x *RenderNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[4] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -559,7 +559,7 @@ func (x *RenderNode) ProtoReflect() protoreflect.Message { // Deprecated: Use RenderNode.ProtoReflect.Descriptor instead. func (*RenderNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{4} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{4} } func (x *RenderNode) GetNode() isRenderNode_Node { @@ -736,7 +736,7 @@ type TextNode struct { func (x *TextNode) Reset() { *x = TextNode{} - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[5] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -748,7 +748,7 @@ func (x *TextNode) String() string { func (*TextNode) ProtoMessage() {} func (x *TextNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[5] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -761,7 +761,7 @@ func (x *TextNode) ProtoReflect() protoreflect.Message { // Deprecated: Use TextNode.ProtoReflect.Descriptor instead. func (*TextNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{5} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{5} } func (x *TextNode) GetContent() string { @@ -792,7 +792,7 @@ type CodeBlockNode struct { func (x *CodeBlockNode) Reset() { *x = CodeBlockNode{} - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[6] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -804,7 +804,7 @@ func (x *CodeBlockNode) String() string { func (*CodeBlockNode) ProtoMessage() {} func (x *CodeBlockNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[6] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -817,7 +817,7 @@ func (x *CodeBlockNode) ProtoReflect() protoreflect.Message { // Deprecated: Use CodeBlockNode.ProtoReflect.Descriptor instead. func (*CodeBlockNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{6} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{6} } func (x *CodeBlockNode) GetLanguage() string { @@ -847,7 +847,7 @@ type DiffNode struct { func (x *DiffNode) Reset() { *x = DiffNode{} - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[7] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -859,7 +859,7 @@ func (x *DiffNode) String() string { func (*DiffNode) ProtoMessage() {} func (x *DiffNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[7] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -872,7 +872,7 @@ func (x *DiffNode) ProtoReflect() protoreflect.Message { // Deprecated: Use DiffNode.ProtoReflect.Descriptor instead. func (*DiffNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{7} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{7} } func (x *DiffNode) GetHunks() []*DiffHunk { @@ -894,7 +894,7 @@ type TableRow struct { func (x *TableRow) Reset() { *x = TableRow{} - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[8] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -906,7 +906,7 @@ func (x *TableRow) String() string { func (*TableRow) ProtoMessage() {} func (x *TableRow) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[8] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -919,7 +919,7 @@ func (x *TableRow) ProtoReflect() protoreflect.Message { // Deprecated: Use TableRow.ProtoReflect.Descriptor instead. func (*TableRow) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{8} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{8} } func (x *TableRow) GetCells() []string { @@ -943,7 +943,7 @@ type TableNode struct { func (x *TableNode) Reset() { *x = TableNode{} - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[9] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -955,7 +955,7 @@ func (x *TableNode) String() string { func (*TableNode) ProtoMessage() {} func (x *TableNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[9] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -968,7 +968,7 @@ func (x *TableNode) ProtoReflect() protoreflect.Message { // Deprecated: Use TableNode.ProtoReflect.Descriptor instead. func (*TableNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{9} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{9} } func (x *TableNode) GetHeaders() []string { @@ -998,7 +998,7 @@ type LinkNode struct { func (x *LinkNode) Reset() { *x = LinkNode{} - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[10] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1010,7 +1010,7 @@ func (x *LinkNode) String() string { func (*LinkNode) ProtoMessage() {} func (x *LinkNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[10] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1023,7 +1023,7 @@ func (x *LinkNode) ProtoReflect() protoreflect.Message { // Deprecated: Use LinkNode.ProtoReflect.Descriptor instead. func (*LinkNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{10} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{10} } func (x *LinkNode) GetText() string { @@ -1053,7 +1053,7 @@ type ListNode struct { func (x *ListNode) Reset() { *x = ListNode{} - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[11] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1065,7 +1065,7 @@ func (x *ListNode) String() string { func (*ListNode) ProtoMessage() {} func (x *ListNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[11] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1078,7 +1078,7 @@ func (x *ListNode) ProtoReflect() protoreflect.Message { // Deprecated: Use ListNode.ProtoReflect.Descriptor instead. func (*ListNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{11} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{11} } func (x *ListNode) GetItems() []*RenderNode { @@ -1107,7 +1107,7 @@ type GroupNode struct { func (x *GroupNode) Reset() { *x = GroupNode{} - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[12] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1119,7 +1119,7 @@ func (x *GroupNode) String() string { func (*GroupNode) ProtoMessage() {} func (x *GroupNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[12] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1132,7 +1132,7 @@ func (x *GroupNode) ProtoReflect() protoreflect.Message { // Deprecated: Use GroupNode.ProtoReflect.Descriptor instead. func (*GroupNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{12} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{12} } func (x *GroupNode) GetChildren() []*RenderNode { @@ -1158,7 +1158,7 @@ type CollapsibleNode struct { func (x *CollapsibleNode) Reset() { *x = CollapsibleNode{} - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[13] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1170,7 +1170,7 @@ func (x *CollapsibleNode) String() string { func (*CollapsibleNode) ProtoMessage() {} func (x *CollapsibleNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[13] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1183,7 +1183,7 @@ func (x *CollapsibleNode) ProtoReflect() protoreflect.Message { // Deprecated: Use CollapsibleNode.ProtoReflect.Descriptor instead. func (*CollapsibleNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{13} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{13} } func (x *CollapsibleNode) GetSummary() string { @@ -1222,7 +1222,7 @@ type SubSessionNode struct { func (x *SubSessionNode) Reset() { *x = SubSessionNode{} - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[14] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1234,7 +1234,7 @@ func (x *SubSessionNode) String() string { func (*SubSessionNode) ProtoMessage() {} func (x *SubSessionNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[14] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1247,7 +1247,7 @@ func (x *SubSessionNode) ProtoReflect() protoreflect.Message { // Deprecated: Use SubSessionNode.ProtoReflect.Descriptor instead. func (*SubSessionNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{14} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{14} } func (x *SubSessionNode) GetSessionId() string { @@ -1296,7 +1296,7 @@ type ActionNode struct { func (x *ActionNode) Reset() { *x = ActionNode{} - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[15] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1308,7 +1308,7 @@ func (x *ActionNode) String() string { func (*ActionNode) ProtoMessage() {} func (x *ActionNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_render_proto_msgTypes[15] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1321,7 +1321,7 @@ func (x *ActionNode) ProtoReflect() protoreflect.Message { // Deprecated: Use ActionNode.ProtoReflect.Descriptor instead. func (*ActionNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_render_proto_rawDescGZIP(), []int{15} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{15} } func (x *ActionNode) GetId() string { @@ -1359,11 +1359,11 @@ func (x *ActionNode) GetProvider() string { return "" } -var File_pluggableharness_render_v1_render_proto protoreflect.FileDescriptor +var File_pluggableharness_render_v1_types_proto protoreflect.FileDescriptor -const file_pluggableharness_render_v1_render_proto_rawDesc = "" + +const file_pluggableharness_render_v1_types_proto_rawDesc = "" + "\n" + - "'pluggableharness/render/v1/render.proto\x12\x1apluggableharness.render.v1\x1a\x1cgoogle/protobuf/struct.proto\"H\n" + + "&pluggableharness/render/v1/types.proto\x12\x1apluggableharness.render.v1\x1a\x1cgoogle/protobuf/struct.proto\"H\n" + "\n" + "RenderTree\x12:\n" + "\x04root\x18\x01 \x01(\v2&.pluggableharness.render.v1.RenderNodeR\x04root\"\xd5\x01\n" + @@ -1462,20 +1462,20 @@ const file_pluggableharness_render_v1_render_proto_rawDesc = "" + "\x13DIFF_LINE_OP_REMOVE\x10\x03B@Z>github.com/pluggableharness/agent/pkg/render/proto/v1;renderv1b\x06proto3" var ( - file_pluggableharness_render_v1_render_proto_rawDescOnce sync.Once - file_pluggableharness_render_v1_render_proto_rawDescData []byte + file_pluggableharness_render_v1_types_proto_rawDescOnce sync.Once + file_pluggableharness_render_v1_types_proto_rawDescData []byte ) -func file_pluggableharness_render_v1_render_proto_rawDescGZIP() []byte { - file_pluggableharness_render_v1_render_proto_rawDescOnce.Do(func() { - file_pluggableharness_render_v1_render_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_render_v1_render_proto_rawDesc), len(file_pluggableharness_render_v1_render_proto_rawDesc))) +func file_pluggableharness_render_v1_types_proto_rawDescGZIP() []byte { + file_pluggableharness_render_v1_types_proto_rawDescOnce.Do(func() { + file_pluggableharness_render_v1_types_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_render_v1_types_proto_rawDesc), len(file_pluggableharness_render_v1_types_proto_rawDesc))) }) - return file_pluggableharness_render_v1_render_proto_rawDescData + return file_pluggableharness_render_v1_types_proto_rawDescData } -var file_pluggableharness_render_v1_render_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_pluggableharness_render_v1_render_proto_msgTypes = make([]protoimpl.MessageInfo, 16) -var file_pluggableharness_render_v1_render_proto_goTypes = []any{ +var file_pluggableharness_render_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_pluggableharness_render_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_pluggableharness_render_v1_types_proto_goTypes = []any{ (Region)(0), // 0: pluggableharness.render.v1.Region (TextStyle)(0), // 1: pluggableharness.render.v1.TextStyle (DiffLineOp)(0), // 2: pluggableharness.render.v1.DiffLineOp @@ -1497,7 +1497,7 @@ var file_pluggableharness_render_v1_render_proto_goTypes = []any{ (*ActionNode)(nil), // 18: pluggableharness.render.v1.ActionNode (*structpb.Struct)(nil), // 19: google.protobuf.Struct } -var file_pluggableharness_render_v1_render_proto_depIdxs = []int32{ +var file_pluggableharness_render_v1_types_proto_depIdxs = []int32{ 7, // 0: pluggableharness.render.v1.RenderTree.root:type_name -> pluggableharness.render.v1.RenderNode 0, // 1: pluggableharness.render.v1.PlacedContent.region:type_name -> pluggableharness.render.v1.Region 3, // 2: pluggableharness.render.v1.PlacedContent.content:type_name -> pluggableharness.render.v1.RenderTree @@ -1527,13 +1527,13 @@ var file_pluggableharness_render_v1_render_proto_depIdxs = []int32{ 0, // [0:22] is the sub-list for field type_name } -func init() { file_pluggableharness_render_v1_render_proto_init() } -func file_pluggableharness_render_v1_render_proto_init() { - if File_pluggableharness_render_v1_render_proto != nil { +func init() { file_pluggableharness_render_v1_types_proto_init() } +func file_pluggableharness_render_v1_types_proto_init() { + if File_pluggableharness_render_v1_types_proto != nil { return } - file_pluggableharness_render_v1_render_proto_msgTypes[1].OneofWrappers = []any{} - file_pluggableharness_render_v1_render_proto_msgTypes[4].OneofWrappers = []any{ + file_pluggableharness_render_v1_types_proto_msgTypes[1].OneofWrappers = []any{} + file_pluggableharness_render_v1_types_proto_msgTypes[4].OneofWrappers = []any{ (*RenderNode_Text)(nil), (*RenderNode_CodeBlock)(nil), (*RenderNode_Diff)(nil), @@ -1545,24 +1545,24 @@ func file_pluggableharness_render_v1_render_proto_init() { (*RenderNode_SubSession)(nil), (*RenderNode_Action)(nil), } - file_pluggableharness_render_v1_render_proto_msgTypes[5].OneofWrappers = []any{} - file_pluggableharness_render_v1_render_proto_msgTypes[6].OneofWrappers = []any{} + file_pluggableharness_render_v1_types_proto_msgTypes[5].OneofWrappers = []any{} + file_pluggableharness_render_v1_types_proto_msgTypes[6].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_render_v1_render_proto_rawDesc), len(file_pluggableharness_render_v1_render_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_render_v1_types_proto_rawDesc), len(file_pluggableharness_render_v1_types_proto_rawDesc)), NumEnums: 3, NumMessages: 16, NumExtensions: 0, NumServices: 0, }, - GoTypes: file_pluggableharness_render_v1_render_proto_goTypes, - DependencyIndexes: file_pluggableharness_render_v1_render_proto_depIdxs, - EnumInfos: file_pluggableharness_render_v1_render_proto_enumTypes, - MessageInfos: file_pluggableharness_render_v1_render_proto_msgTypes, + GoTypes: file_pluggableharness_render_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_render_v1_types_proto_depIdxs, + EnumInfos: file_pluggableharness_render_v1_types_proto_enumTypes, + MessageInfos: file_pluggableharness_render_v1_types_proto_msgTypes, }.Build() - File_pluggableharness_render_v1_render_proto = out.File - file_pluggableharness_render_v1_render_proto_goTypes = nil - file_pluggableharness_render_v1_render_proto_depIdxs = nil + File_pluggableharness_render_v1_types_proto = out.File + file_pluggableharness_render_v1_types_proto_goTypes = nil + file_pluggableharness_render_v1_types_proto_depIdxs = nil } diff --git a/pkg/render/tree.go b/pkg/render/tree.go new file mode 100644 index 0000000..b798665 --- /dev/null +++ b/pkg/render/tree.go @@ -0,0 +1,13 @@ +package render + +import renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" + +// Tree wraps a single root RenderNode into the RenderTree every category's +// Render RPC returns +// (docs/specifications/frontend/render-tree.md#rendertree: "The tree root +// is just a node, so RenderTree wraps a single root RenderNode"). To place +// more than one top-level node, wrap them first with Group, List, or +// Collapsible — RenderTree.Root is a single node, not a node slice. +func Tree(root *renderv1.RenderNode) *renderv1.RenderTree { + return &renderv1.RenderTree{Root: root} +} diff --git a/pkg/render/tree_test.go b/pkg/render/tree_test.go new file mode 100644 index 0000000..22c606a --- /dev/null +++ b/pkg/render/tree_test.go @@ -0,0 +1,26 @@ +package render_test + +import ( + "testing" + + "github.com/pluggableharness/agent/pkg/render" +) + +func TestTree(t *testing.T) { + t.Parallel() + + root := render.Text("hello") + got := render.Tree(root) + if got.GetRoot() != root { + t.Errorf("Tree(root).GetRoot() = %v, want the same node pointer %v", got.GetRoot(), root) + } +} + +func TestTreeNilRoot(t *testing.T) { + t.Parallel() + + got := render.Tree(nil) + if got.GetRoot() != nil { + t.Errorf("Tree(nil).GetRoot() = %v, want nil", got.GetRoot()) + } +} diff --git a/pkg/render/version.go b/pkg/render/version.go new file mode 100644 index 0000000..62e16e0 --- /dev/null +++ b/pkg/render/version.go @@ -0,0 +1,69 @@ +package render + +import ( + "fmt" + "sync" + + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// VersionedRenderer decodes one specific schema_version's opaque payload +// bytes into a RenderTree. A plugin registers one VersionedRenderer per +// schema_version it has ever emitted +// (docs/specifications/frontend/render-tree.md#schema-versioning) with a +// VersionRegistry, and keeps every past version's VersionedRenderer +// registered for as long as any retained session can still reference it — +// the same permanence guarantee the wire message shapes themselves carry. +type VersionedRenderer func(payload []byte) (*renderv1.RenderTree, error) + +// VersionRegistry dispatches a RenderRequest's schema_version to the +// VersionedRenderer a plugin registered for it, so a plugin's Render +// implementation branches on schema_version (as +// docs/specifications/frontend/render-tree.md#schema-versioning requires) +// instead of every plugin author hand-rolling that dispatch. +// +// A VersionRegistry is safe for concurrent use: Register is expected to +// run a handful of times at process start (one call per schema_version a +// plugin has ever shipped) before Render starts serving live/replayed +// RenderRequests, but guarding the map with a sync.RWMutex rather than +// requiring an explicit "freeze" step keeps VersionRegistry +// misuse-resistant — a late Register (e.g. a plugin that loads version +// decoders lazily) or a concurrent Register from a background goroutine +// stays safe instead of racing readers. Register calls are rare and cheap, +// so the lock's overhead is negligible next to the safety it buys. +type VersionRegistry struct { + mu sync.RWMutex + renderers map[string]VersionedRenderer +} + +// NewVersionRegistry builds an empty VersionRegistry. +func NewVersionRegistry() *VersionRegistry { + return &VersionRegistry{renderers: make(map[string]VersionedRenderer)} +} + +// Register associates a schema_version string with the VersionedRenderer +// that decodes payloads of that shape. Registering the same version twice +// replaces the previously registered VersionedRenderer. +func (r *VersionRegistry) Register(version string, fn VersionedRenderer) { + r.mu.Lock() + defer r.mu.Unlock() + r.renderers[version] = fn +} + +// Render dispatches to the VersionedRenderer registered for +// schemaVersion and returns its result. It returns ErrUnknownSchemaVersion, +// wrapped with the requested version, if nothing is registered for +// schemaVersion — including when the registry is empty. +func (r *VersionRegistry) Render(schemaVersion string, payload []byte) (*renderv1.RenderTree, error) { + r.mu.RLock() + fn, ok := r.renderers[schemaVersion] + r.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("render: schema version %q: %w", schemaVersion, ErrUnknownSchemaVersion) + } + tree, err := fn(payload) + if err != nil { + return nil, fmt.Errorf("render: schema version %q: %w", schemaVersion, err) + } + return tree, nil +} diff --git a/pkg/render/version_test.go b/pkg/render/version_test.go new file mode 100644 index 0000000..36f6de7 --- /dev/null +++ b/pkg/render/version_test.go @@ -0,0 +1,116 @@ +package render_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/pluggableharness/agent/pkg/render" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +func TestVersionRegistryRenderKnownVersion(t *testing.T) { + t.Parallel() + + reg := render.NewVersionRegistry() + reg.Register("v1", func(payload []byte) (*renderv1.RenderTree, error) { + return render.Tree(render.Text(string(payload))), nil + }) + reg.Register("v2", func(payload []byte) (*renderv1.RenderTree, error) { + return render.Tree(render.Code("go", string(payload))), nil + }) + + got, err := reg.Render("v2", []byte("package main")) + if err != nil { + t.Fatalf("Render(v2): %v", err) + } + block := got.GetRoot().GetCodeBlock() + if block == nil || block.GetContent() != "package main" { + t.Errorf("Render(v2) routed to wrong renderer, got root = %v", got.GetRoot()) + } +} + +func TestVersionRegistryRenderUnknownVersion(t *testing.T) { + t.Parallel() + + reg := render.NewVersionRegistry() + reg.Register("v1", func(payload []byte) (*renderv1.RenderTree, error) { + return render.Tree(render.Text(string(payload))), nil + }) + + _, err := reg.Render("v99", []byte("x")) + if !errors.Is(err, render.ErrUnknownSchemaVersion) { + t.Errorf("Render(v99) error = %v, want wrapping ErrUnknownSchemaVersion", err) + } +} + +func TestVersionRegistryEmpty(t *testing.T) { + t.Parallel() + + reg := render.NewVersionRegistry() + _, err := reg.Render("anything", nil) + if !errors.Is(err, render.ErrUnknownSchemaVersion) { + t.Errorf("Render on empty registry error = %v, want wrapping ErrUnknownSchemaVersion", err) + } +} + +func TestVersionRegistryRenderPropagatesRendererError(t *testing.T) { + t.Parallel() + + wantErr := errors.New("boom") + reg := render.NewVersionRegistry() + reg.Register("v1", func(_ []byte) (*renderv1.RenderTree, error) { + return nil, wantErr + }) + + _, err := reg.Render("v1", nil) + if !errors.Is(err, wantErr) { + t.Errorf("Render(v1) error = %v, want wrapping %v", err, wantErr) + } +} + +func TestVersionRegistryRegisterOverwrites(t *testing.T) { + t.Parallel() + + reg := render.NewVersionRegistry() + reg.Register("v1", func(_ []byte) (*renderv1.RenderTree, error) { + return render.Tree(render.Text("first")), nil + }) + reg.Register("v1", func(_ []byte) (*renderv1.RenderTree, error) { + return render.Tree(render.Text("second")), nil + }) + + got, err := reg.Render("v1", nil) + if err != nil { + t.Fatalf("Render(v1): %v", err) + } + if content := got.GetRoot().GetText().GetContent(); content != "second" { + t.Errorf("Render(v1) content = %q, want %q (last Register wins)", content, "second") + } +} + +func TestVersionRegistryConcurrentRegisterAndRender(t *testing.T) { + t.Parallel() + + reg := render.NewVersionRegistry() + const n = 20 + done := make(chan struct{}, n*2) + + for i := range n { + version := fmt.Sprintf("v%d", i) + go func() { + reg.Register(version, func(_ []byte) (*renderv1.RenderTree, error) { + return render.Tree(render.Text(version)), nil + }) + done <- struct{}{} + }() + go func() { + _, _ = reg.Render(version, nil) //nolint:errcheck // exercising concurrent access, not asserting outcome + done <- struct{}{} + }() + } + + for range n * 2 { + <-done + } +} diff --git a/pkg/schema/builders.go b/pkg/schema/builders.go new file mode 100644 index 0000000..48eddd3 --- /dev/null +++ b/pkg/schema/builders.go @@ -0,0 +1,103 @@ +package schema + +import ( + "fmt" + + schemav1 "github.com/pluggableharness/agent/pkg/schema/proto/v1" +) + +// Object builds an OBJECT schema node with the given named property +// sub-schemas. WithRequired(names...) marks a subset of properties' keys +// as required; every named property MUST already be a key of properties, +// or Object returns an error. Object also rejects a nil entry in +// properties — a nil *schemav1.Schema is not a valid subset schema, and +// letting it through would produce a Schema that fails validation only +// much later, at the kernel/adapter boundary rather than at construction +// time. +func Object(properties map[string]*schemav1.Schema, opts ...Option) (*schemav1.Schema, error) { + o := resolve(opts) + + for name, prop := range properties { + if prop == nil { + return nil, fmt.Errorf("schema: object: property %q is nil", name) + } + } + for _, name := range o.required { + if _, ok := properties[name]; !ok { + return nil, fmt.Errorf("schema: object: required name %q is not a key of properties", name) + } + } + + return &schemav1.Schema{ + Type: schemav1.SchemaType_SCHEMA_TYPE_OBJECT, + Description: o.description, + Properties: properties, + Required: o.required, + }, nil +} + +// String builds a STRING schema node. WithEnum constrains the value to a +// fixed set of strings; WithDescription sets the description. +func String(opts ...Option) *schemav1.Schema { + o := resolve(opts) + return &schemav1.Schema{ + Type: schemav1.SchemaType_SCHEMA_TYPE_STRING, + Description: o.description, + EnumValues: o.enumValues, + } +} + +// Number builds a NUMBER schema node. model/data-types.md#tool-schema's +// subset does not distinguish integer from floating point — both are +// SCHEMA_TYPE_NUMBER on the wire — so Number is the one builder that +// actually produces this type; Integer is a wire-identical convenience +// alias of it. +func Number(opts ...Option) *schemav1.Schema { + o := resolve(opts) + return &schemav1.Schema{ + Type: schemav1.SchemaType_SCHEMA_TYPE_NUMBER, + Description: o.description, + } +} + +// Integer builds a NUMBER schema node, identical on the wire to Number. +// It exists purely so a Go author can write down integer intent at the +// call site; no adapter or downstream consumer can distinguish the +// result from Number's, because the generated SchemaType enum has no +// dedicated integer value (see doc.go). +func Integer(opts ...Option) *schemav1.Schema { + return Number(opts...) +} + +// Boolean builds a BOOLEAN schema node. +func Boolean(opts ...Option) *schemav1.Schema { + o := resolve(opts) + return &schemav1.Schema{ + Type: schemav1.SchemaType_SCHEMA_TYPE_BOOLEAN, + Description: o.description, + } +} + +// Array builds an ARRAY schema node whose elements must each satisfy +// items. items MUST be non-nil — an ARRAY node without an element schema +// is not representable per model/data-types.md#tool-schema ("items — +// ARRAY only: the schema every element of the array must satisfy") — so +// Array returns an error rather than silently producing an invalid node. +func Array(items *schemav1.Schema, opts ...Option) (*schemav1.Schema, error) { + if items == nil { + return nil, fmt.Errorf("schema: array: items must not be nil") + } + o := resolve(opts) + return &schemav1.Schema{ + Type: schemav1.SchemaType_SCHEMA_TYPE_ARRAY, + Description: o.description, + Items: items, + }, nil +} + +// Enum builds a STRING schema node constrained to one of values — sugar +// for String(WithEnum(values...)). Use String directly to combine an enum +// with a description. +func Enum(values ...string) *schemav1.Schema { + return String(WithEnum(values...)) +} diff --git a/pkg/schema/builders_test.go b/pkg/schema/builders_test.go new file mode 100644 index 0000000..4c9d13d --- /dev/null +++ b/pkg/schema/builders_test.go @@ -0,0 +1,310 @@ +package schema_test + +import ( + "errors" + "reflect" + "testing" + + "github.com/pluggableharness/agent/pkg/schema" + schemav1 "github.com/pluggableharness/agent/pkg/schema/proto/v1" +) + +// TestString asserts String produces a STRING node carrying the +// description and enum values from the given options. +func TestString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts []schema.Option + want *schemav1.Schema + }{ + { + name: "no options", + opts: nil, + want: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_STRING}, + }, + { + name: "description", + opts: []schema.Option{schema.WithDescription("a name")}, + want: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_STRING, Description: "a name"}, + }, + { + name: "enum", + opts: []schema.Option{schema.WithEnum("red", "green", "blue")}, + want: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_STRING, EnumValues: []string{"red", "green", "blue"}}, + }, + { + name: "description and enum", + opts: []schema.Option{schema.WithDescription("a color"), schema.WithEnum("red", "green")}, + want: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_STRING, Description: "a color", EnumValues: []string{"red", "green"}}, + }, + { + name: "nil option is a no-op", + opts: []schema.Option{nil, schema.WithDescription("still set")}, + want: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_STRING, Description: "still set"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := schema.String(tt.opts...) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("String(%v) = %+v, want %+v", tt.opts, got, tt.want) + } + }) + } +} + +// TestEnum asserts Enum is equivalent sugar for String(WithEnum(...)). +func TestEnum(t *testing.T) { + t.Parallel() + + got := schema.Enum("a", "b", "c") + want := schema.String(schema.WithEnum("a", "b", "c")) + if !reflect.DeepEqual(got, want) { + t.Errorf("Enum(a, b, c) = %+v, want %+v", got, want) + } + if got.GetType() != schemav1.SchemaType_SCHEMA_TYPE_STRING { + t.Errorf("Enum(...).Type = %v, want SCHEMA_TYPE_STRING", got.GetType()) + } +} + +// TestNumber asserts Number produces a NUMBER node. +func TestNumber(t *testing.T) { + t.Parallel() + + got := schema.Number(schema.WithDescription("a count")) + want := &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_NUMBER, Description: "a count"} + if !reflect.DeepEqual(got, want) { + t.Errorf("Number(...) = %+v, want %+v", got, want) + } +} + +// TestIntegerAliasesNumber asserts Integer is wire-identical to Number, +// since the generated SchemaType enum has no dedicated integer value. +func TestIntegerAliasesNumber(t *testing.T) { + t.Parallel() + + got := schema.Integer(schema.WithDescription("a count")) + want := schema.Number(schema.WithDescription("a count")) + if !reflect.DeepEqual(got, want) { + t.Errorf("Integer(...) = %+v, want %+v (same as Number)", got, want) + } +} + +// TestBoolean asserts Boolean produces a BOOLEAN node. +func TestBoolean(t *testing.T) { + t.Parallel() + + got := schema.Boolean(schema.WithDescription("a flag")) + want := &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_BOOLEAN, Description: "a flag"} + if !reflect.DeepEqual(got, want) { + t.Errorf("Boolean(...) = %+v, want %+v", got, want) + } +} + +// TestBooleanIgnoresEnumOption asserts an inapplicable option (WithEnum on +// a non-String builder) is a silent no-op, per the Option doc comment, +// rather than corrupting the produced node. +func TestBooleanIgnoresEnumOption(t *testing.T) { + t.Parallel() + + got := schema.Boolean(schema.WithEnum("yes", "no")) + want := &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_BOOLEAN} + if !reflect.DeepEqual(got, want) { + t.Errorf("Boolean(WithEnum(...)) = %+v, want %+v (enum ignored)", got, want) + } +} + +// TestArray asserts Array wraps an items schema and rejects a nil items +// argument, since an ARRAY node without an element schema is not +// representable per model/data-types.md#tool-schema. +func TestArray(t *testing.T) { + t.Parallel() + + t.Run("valid items", func(t *testing.T) { + t.Parallel() + + items := schema.String() + got, err := schema.Array(items, schema.WithDescription("a list of strings")) + if err != nil { + t.Fatalf("Array(items, ...) returned error: %v", err) + } + want := &schemav1.Schema{ + Type: schemav1.SchemaType_SCHEMA_TYPE_ARRAY, + Description: "a list of strings", + Items: items, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Array(items, ...) = %+v, want %+v", got, want) + } + }) + + t.Run("nil items", func(t *testing.T) { + t.Parallel() + + got, err := schema.Array(nil) + if err == nil { + t.Fatalf("Array(nil) returned nil error, want error") + } + if got != nil { + t.Errorf("Array(nil) = %+v, want nil on error", got) + } + }) +} + +// TestObject asserts Object attaches the given properties and, via +// WithRequired, a validated required list. +func TestObject(t *testing.T) { + t.Parallel() + + t.Run("properties and required", func(t *testing.T) { + t.Parallel() + + props := map[string]*schemav1.Schema{ + "name": schema.String(schema.WithDescription("display name")), + "age": schema.Integer(), + } + got, err := schema.Object(props, schema.WithDescription("a person"), schema.WithRequired("name")) + if err != nil { + t.Fatalf("Object(...) returned error: %v", err) + } + want := &schemav1.Schema{ + Type: schemav1.SchemaType_SCHEMA_TYPE_OBJECT, + Description: "a person", + Properties: props, + Required: []string{"name"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Object(...) = %+v, want %+v", got, want) + } + }) + + t.Run("no options", func(t *testing.T) { + t.Parallel() + + props := map[string]*schemav1.Schema{"name": schema.String()} + got, err := schema.Object(props) + if err != nil { + t.Fatalf("Object(props) returned error: %v", err) + } + if got.GetType() != schemav1.SchemaType_SCHEMA_TYPE_OBJECT { + t.Errorf("Object(props).Type = %v, want SCHEMA_TYPE_OBJECT", got.GetType()) + } + if len(got.GetRequired()) != 0 { + t.Errorf("Object(props).Required = %v, want empty", got.GetRequired()) + } + }) + + t.Run("required name absent from properties", func(t *testing.T) { + t.Parallel() + + props := map[string]*schemav1.Schema{"name": schema.String()} + got, err := schema.Object(props, schema.WithRequired("nonexistent")) + if err == nil { + t.Fatalf("Object(...) returned nil error, want error for unknown required name") + } + if got != nil { + t.Errorf("Object(...) = %+v, want nil on error", got) + } + }) + + t.Run("nil property value", func(t *testing.T) { + t.Parallel() + + props := map[string]*schemav1.Schema{"broken": nil} + got, err := schema.Object(props) + if err == nil { + t.Fatalf("Object(...) returned nil error, want error for nil property") + } + if got != nil { + t.Errorf("Object(...) = %+v, want nil on error", got) + } + }) +} + +// TestObjectArrayOfObjectComposition builds a realistic nested schema — +// an object with an array-of-object property — to prove builder +// composition works end to end, the way a real tool's input_schema would +// be assembled. +func TestObjectArrayOfObjectComposition(t *testing.T) { + t.Parallel() + + item, err := schema.Object(map[string]*schemav1.Schema{ + "id": schema.String(schema.WithDescription("item ID")), + "tags": schema.String(schema.WithEnum("urgent", "normal", "low")), + }, schema.WithRequired("id")) + if err != nil { + t.Fatalf("Object(item) returned error: %v", err) + } + + items, err := schema.Array(item, schema.WithDescription("the work items")) + if err != nil { + t.Fatalf("Array(item) returned error: %v", err) + } + + root, err := schema.Object(map[string]*schemav1.Schema{ + "title": schema.String(schema.WithDescription("batch title")), + "items": items, + "count": schema.Integer(schema.WithDescription("expected item count")), + }, schema.WithDescription("a batch of work items"), schema.WithRequired("title", "items")) + if err != nil { + t.Fatalf("Object(root) returned error: %v", err) + } + + if root.GetType() != schemav1.SchemaType_SCHEMA_TYPE_OBJECT { + t.Fatalf("root.Type = %v, want SCHEMA_TYPE_OBJECT", root.GetType()) + } + wantRequired := []string{"title", "items"} + if !reflect.DeepEqual(root.GetRequired(), wantRequired) { + t.Errorf("root.Required = %v, want %v", root.GetRequired(), wantRequired) + } + + gotItems := root.GetProperties()["items"] + if gotItems.GetType() != schemav1.SchemaType_SCHEMA_TYPE_ARRAY { + t.Fatalf("root.Properties[items].Type = %v, want SCHEMA_TYPE_ARRAY", gotItems.GetType()) + } + gotItem := gotItems.GetItems() + if gotItem.GetType() != schemav1.SchemaType_SCHEMA_TYPE_OBJECT { + t.Fatalf("root.Properties[items].Items.Type = %v, want SCHEMA_TYPE_OBJECT", gotItem.GetType()) + } + if !reflect.DeepEqual(gotItem.GetRequired(), []string{"id"}) { + t.Errorf("item.Required = %v, want [id]", gotItem.GetRequired()) + } + gotTags := gotItem.GetProperties()["tags"] + wantTags := []string{"urgent", "normal", "low"} + if !reflect.DeepEqual(gotTags.GetEnumValues(), wantTags) { + t.Errorf("item.Properties[tags].EnumValues = %v, want %v", gotTags.GetEnumValues(), wantTags) + } + + gotCount := root.GetProperties()["count"] + if gotCount.GetType() != schemav1.SchemaType_SCHEMA_TYPE_NUMBER { + t.Errorf("root.Properties[count].Type = %v, want SCHEMA_TYPE_NUMBER", gotCount.GetType()) + } +} + +// TestObjectErrorsAreWrapped asserts Object's error messages carry the +// "schema: object:" prefix this package's error-wrapping convention +// requires, and are plain errors (not sentinels) since each failure +// carries call-specific data (a name) rather than being a fixed condition. +func TestObjectErrorsAreWrapped(t *testing.T) { + t.Parallel() + + _, err := schema.Object(map[string]*schemav1.Schema{"a": schema.String()}, schema.WithRequired("missing")) + if err == nil { + t.Fatalf("Object(...) returned nil error, want error") + } + if got, want := err.Error(), "schema: object: "; len(got) < len(want) || got[:len(want)] != want { + t.Errorf("Object(...) error = %q, want prefix %q", got, want) + } + // Sanity: errors.Is against an unrelated sentinel correctly reports false + // rather than panicking, confirming this is an ordinary %w-wrapped error. + if errors.Is(err, errArbitrarySentinel) { + t.Errorf("errors.Is(err, unrelated sentinel) = true, want false") + } +} + +var errArbitrarySentinel = errors.New("unrelated") diff --git a/pkg/schema/doc.go b/pkg/schema/doc.go new file mode 100644 index 0000000..be25ba3 --- /dev/null +++ b/pkg/schema/doc.go @@ -0,0 +1,58 @@ +// Package schema is the plugin-author-facing builder layer over +// pluggableharness.schema.v1.Schema (pkg/schema/proto/v1), the restricted +// JSON-Schema subset every tool provider's ToolSchema.input_schema/ +// output_schema (docs/specifications/tool/protocol.md#getschema) and every +// slashcommand provider's SlashCommandSpec.input_schema +// (docs/specifications/slashcommand/data-types.md) is written in. The +// subset is defined once, canonically, in +// docs/specifications/model/data-types.md#tool-schema — tool/protocol.md +// and slashcommand/data-types.md both point back to that section rather +// than redefining it, and this package follows the same rule: the subset +// is described here for context, but the spec section is ground truth. +// +// # The supported subset +// +// Every adapter MUST support exactly these JSON-Schema keywords, and no +// others: +// +// - type — one of object, string, number, boolean, array. There is no +// dedicated integer type: model/data-types.md#tool-schema and the +// generated SchemaType enum both fold integer into number ("the +// subset does not distinguish the two"). Integer in this package is a +// thin, wire-identical alias of Number, kept for authors who want to +// express integer intent in Go source even though nothing on the wire +// records that intent. +// - properties — OBJECT only: named sub-schemas. +// - required — OBJECT only: which of properties' keys are mandatory. +// Every name MUST already be a key of properties; Object returns an +// error otherwise. +// - enum — STRING only: constrains the value to one of a fixed set of +// strings. +// - items — ARRAY only: the schema every element MUST satisfy. Array +// returns an error if items is nil, since an ARRAY node without one is +// not representable per model/data-types.md#tool-schema. +// - description — every node: human-readable text shown to the model +// during tool selection and in plan-diff UI. +// +// Deliberately absent, and NOT exposed by any builder in this package +// because the generated Schema message has no field for them: oneOf, +// anyOf, allOf, $ref, pattern (regex constraints), format, and non-trivial +// additionalProperties schemas. A tool or slashcommand author cannot reach +// for these through this package — there is no builder call that produces +// them — which is the enforcement mechanism: the restriction lives in the +// Go type system, not in runtime validation. +// +// # Usage +// +// Every builder returns *schemav1.Schema (or, for Object and Array, whose +// arguments can be invalid in ways the type system does not otherwise +// catch, (*schemav1.Schema, error)). Object's properties compose from +// nested builder calls, including Array-of-Object, the same way a +// hand-written JSON Schema document nests: +// +// itemSchema, err := schema.Object(map[string]*schemav1.Schema{ +// "name": schema.String(schema.WithDescription("display name")), +// }, schema.WithRequired("name")) +// +// listSchema, err := schema.Array(itemSchema, schema.WithDescription("items")) +package schema diff --git a/pkg/schema/options.go b/pkg/schema/options.go new file mode 100644 index 0000000..c27f402 --- /dev/null +++ b/pkg/schema/options.go @@ -0,0 +1,55 @@ +package schema + +// Option configures the optional, per-node fields consumed by Object, +// String, Number, Integer, Boolean, and Array. A single Option type is +// shared across every builder rather than one distinct type per builder: +// each builder reads only the options fields meaningful for its own +// SchemaType (per doc.go's "supported subset" list) and silently ignores +// the rest, so passing e.g. WithEnum to Boolean is a no-op, not a compile +// error or a runtime panic — the same "invalid option is a no-op" +// convention every functional-options type in this codebase follows. +type Option func(*options) + +// options is the unexported target struct every Option mutates. Builders +// translate the populated struct into the generated schemav1.Schema +// fields they each care about. +type options struct { + description string + enumValues []string + required []string +} + +// WithDescription sets the human-readable description shown to the model +// during tool selection and in plan-diff UI. Meaningful on every builder. +func WithDescription(description string) Option { + return func(o *options) { o.description = description } +} + +// WithEnum constrains a String schema's value to one of values. Meaningful +// only on String — per model/data-types.md#tool-schema, enum applies to a +// STRING-typed node, not to a distinct type of its own. A no-op on every +// other builder. +func WithEnum(values ...string) Option { + return func(o *options) { o.enumValues = values } +} + +// WithRequired marks the given property names as required. Meaningful +// only on Object, where every name MUST already be a key of the +// properties map passed to Object — Object returns an error otherwise. A +// no-op on every other builder. +func WithRequired(names ...string) Option { + return func(o *options) { o.required = names } +} + +// resolve applies every non-nil opt in order and returns the resulting +// options value. Defaults are the options zero value: empty description, +// no enum values, no required names. +func resolve(opts []Option) options { + var o options + for _, opt := range opts { + if opt != nil { + opt(&o) + } + } + return o +} diff --git a/pkg/schema/proto/v1/schema.pb.go b/pkg/schema/proto/v1/types.pb.go similarity index 78% rename from pkg/schema/proto/v1/schema.pb.go rename to pkg/schema/proto/v1/types.pb.go index 9ad78e2..b66dc02 100644 --- a/pkg/schema/proto/v1/schema.pb.go +++ b/pkg/schema/proto/v1/types.pb.go @@ -2,7 +2,7 @@ // versions: // protoc-gen-go v1.36.11 // protoc (unknown) -// source: pluggableharness/schema/v1/schema.proto +// source: pluggableharness/schema/v1/types.proto // Package pluggableharness.schema.v1 defines the restricted JSON-Schema subset // described in specifications/model.md §6, shared by tool input/output @@ -84,11 +84,11 @@ func (x SchemaType) String() string { } func (SchemaType) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_schema_v1_schema_proto_enumTypes[0].Descriptor() + return file_pluggableharness_schema_v1_types_proto_enumTypes[0].Descriptor() } func (SchemaType) Type() protoreflect.EnumType { - return &file_pluggableharness_schema_v1_schema_proto_enumTypes[0] + return &file_pluggableharness_schema_v1_types_proto_enumTypes[0] } func (x SchemaType) Number() protoreflect.EnumNumber { @@ -97,7 +97,7 @@ func (x SchemaType) Number() protoreflect.EnumNumber { // Deprecated: Use SchemaType.Descriptor instead. func (SchemaType) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_schema_v1_schema_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_schema_v1_types_proto_rawDescGZIP(), []int{0} } // Schema is one node of the restricted JSON-Schema subset. It is @@ -134,7 +134,7 @@ type Schema struct { func (x *Schema) Reset() { *x = Schema{} - mi := &file_pluggableharness_schema_v1_schema_proto_msgTypes[0] + mi := &file_pluggableharness_schema_v1_types_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -146,7 +146,7 @@ func (x *Schema) String() string { func (*Schema) ProtoMessage() {} func (x *Schema) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_schema_v1_schema_proto_msgTypes[0] + mi := &file_pluggableharness_schema_v1_types_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -159,7 +159,7 @@ func (x *Schema) ProtoReflect() protoreflect.Message { // Deprecated: Use Schema.ProtoReflect.Descriptor instead. func (*Schema) Descriptor() ([]byte, []int) { - return file_pluggableharness_schema_v1_schema_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_schema_v1_types_proto_rawDescGZIP(), []int{0} } func (x *Schema) GetType() SchemaType { @@ -204,11 +204,11 @@ func (x *Schema) GetEnumValues() []string { return nil } -var File_pluggableharness_schema_v1_schema_proto protoreflect.FileDescriptor +var File_pluggableharness_schema_v1_types_proto protoreflect.FileDescriptor -const file_pluggableharness_schema_v1_schema_proto_rawDesc = "" + +const file_pluggableharness_schema_v1_types_proto_rawDesc = "" + "\n" + - "'pluggableharness/schema/v1/schema.proto\x12\x1apluggableharness.schema.v1\"\x94\x03\n" + + "&pluggableharness/schema/v1/types.proto\x12\x1apluggableharness.schema.v1\"\x94\x03\n" + "\x06Schema\x12:\n" + "\x04type\x18\x01 \x01(\x0e2&.pluggableharness.schema.v1.SchemaTypeR\x04type\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12R\n" + @@ -232,25 +232,25 @@ const file_pluggableharness_schema_v1_schema_proto_rawDesc = "" + "\x11SCHEMA_TYPE_ARRAY\x10\x05B@Z>github.com/pluggableharness/agent/pkg/schema/proto/v1;schemav1b\x06proto3" var ( - file_pluggableharness_schema_v1_schema_proto_rawDescOnce sync.Once - file_pluggableharness_schema_v1_schema_proto_rawDescData []byte + file_pluggableharness_schema_v1_types_proto_rawDescOnce sync.Once + file_pluggableharness_schema_v1_types_proto_rawDescData []byte ) -func file_pluggableharness_schema_v1_schema_proto_rawDescGZIP() []byte { - file_pluggableharness_schema_v1_schema_proto_rawDescOnce.Do(func() { - file_pluggableharness_schema_v1_schema_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_schema_v1_schema_proto_rawDesc), len(file_pluggableharness_schema_v1_schema_proto_rawDesc))) +func file_pluggableharness_schema_v1_types_proto_rawDescGZIP() []byte { + file_pluggableharness_schema_v1_types_proto_rawDescOnce.Do(func() { + file_pluggableharness_schema_v1_types_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_schema_v1_types_proto_rawDesc), len(file_pluggableharness_schema_v1_types_proto_rawDesc))) }) - return file_pluggableharness_schema_v1_schema_proto_rawDescData + return file_pluggableharness_schema_v1_types_proto_rawDescData } -var file_pluggableharness_schema_v1_schema_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_pluggableharness_schema_v1_schema_proto_msgTypes = make([]protoimpl.MessageInfo, 2) -var file_pluggableharness_schema_v1_schema_proto_goTypes = []any{ +var file_pluggableharness_schema_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_pluggableharness_schema_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_pluggableharness_schema_v1_types_proto_goTypes = []any{ (SchemaType)(0), // 0: pluggableharness.schema.v1.SchemaType (*Schema)(nil), // 1: pluggableharness.schema.v1.Schema nil, // 2: pluggableharness.schema.v1.Schema.PropertiesEntry } -var file_pluggableharness_schema_v1_schema_proto_depIdxs = []int32{ +var file_pluggableharness_schema_v1_types_proto_depIdxs = []int32{ 0, // 0: pluggableharness.schema.v1.Schema.type:type_name -> pluggableharness.schema.v1.SchemaType 2, // 1: pluggableharness.schema.v1.Schema.properties:type_name -> pluggableharness.schema.v1.Schema.PropertiesEntry 1, // 2: pluggableharness.schema.v1.Schema.items:type_name -> pluggableharness.schema.v1.Schema @@ -262,27 +262,27 @@ var file_pluggableharness_schema_v1_schema_proto_depIdxs = []int32{ 0, // [0:4] is the sub-list for field type_name } -func init() { file_pluggableharness_schema_v1_schema_proto_init() } -func file_pluggableharness_schema_v1_schema_proto_init() { - if File_pluggableharness_schema_v1_schema_proto != nil { +func init() { file_pluggableharness_schema_v1_types_proto_init() } +func file_pluggableharness_schema_v1_types_proto_init() { + if File_pluggableharness_schema_v1_types_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_schema_v1_schema_proto_rawDesc), len(file_pluggableharness_schema_v1_schema_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_schema_v1_types_proto_rawDesc), len(file_pluggableharness_schema_v1_types_proto_rawDesc)), NumEnums: 1, NumMessages: 2, NumExtensions: 0, NumServices: 0, }, - GoTypes: file_pluggableharness_schema_v1_schema_proto_goTypes, - DependencyIndexes: file_pluggableharness_schema_v1_schema_proto_depIdxs, - EnumInfos: file_pluggableharness_schema_v1_schema_proto_enumTypes, - MessageInfos: file_pluggableharness_schema_v1_schema_proto_msgTypes, + GoTypes: file_pluggableharness_schema_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_schema_v1_types_proto_depIdxs, + EnumInfos: file_pluggableharness_schema_v1_types_proto_enumTypes, + MessageInfos: file_pluggableharness_schema_v1_types_proto_msgTypes, }.Build() - File_pluggableharness_schema_v1_schema_proto = out.File - file_pluggableharness_schema_v1_schema_proto_goTypes = nil - file_pluggableharness_schema_v1_schema_proto_depIdxs = nil + File_pluggableharness_schema_v1_types_proto = out.File + file_pluggableharness_schema_v1_types_proto_goTypes = nil + file_pluggableharness_schema_v1_types_proto_depIdxs = nil } diff --git a/pkg/session/proto/v1/session.pb.go b/pkg/session/proto/v1/types.pb.go similarity index 79% rename from pkg/session/proto/v1/session.pb.go rename to pkg/session/proto/v1/types.pb.go index d222d97..558721f 100644 --- a/pkg/session/proto/v1/session.pb.go +++ b/pkg/session/proto/v1/types.pb.go @@ -2,7 +2,7 @@ // versions: // protoc-gen-go v1.36.11 // protoc (unknown) -// source: pluggableharness/session/v1/session.proto +// source: pluggableharness/session/v1/types.proto // Package pluggableharness.session.v1 defines the session lifecycle status enum // shared by kernel-callbacks.md §1's RunSessionResult, state-backend.md @@ -92,11 +92,11 @@ func (x SessionStatus) String() string { } func (SessionStatus) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_session_v1_session_proto_enumTypes[0].Descriptor() + return file_pluggableharness_session_v1_types_proto_enumTypes[0].Descriptor() } func (SessionStatus) Type() protoreflect.EnumType { - return &file_pluggableharness_session_v1_session_proto_enumTypes[0] + return &file_pluggableharness_session_v1_types_proto_enumTypes[0] } func (x SessionStatus) Number() protoreflect.EnumNumber { @@ -105,7 +105,7 @@ func (x SessionStatus) Number() protoreflect.EnumNumber { // Deprecated: Use SessionStatus.Descriptor instead. func (SessionStatus) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_session_v1_session_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_session_v1_types_proto_rawDescGZIP(), []int{0} } // SessionInfo is a session's shareable summary, mirroring @@ -144,7 +144,7 @@ type SessionInfo struct { func (x *SessionInfo) Reset() { *x = SessionInfo{} - mi := &file_pluggableharness_session_v1_session_proto_msgTypes[0] + mi := &file_pluggableharness_session_v1_types_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -156,7 +156,7 @@ func (x *SessionInfo) String() string { func (*SessionInfo) ProtoMessage() {} func (x *SessionInfo) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_session_v1_session_proto_msgTypes[0] + mi := &file_pluggableharness_session_v1_types_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -169,7 +169,7 @@ func (x *SessionInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionInfo.ProtoReflect.Descriptor instead. func (*SessionInfo) Descriptor() ([]byte, []int) { - return file_pluggableharness_session_v1_session_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_session_v1_types_proto_rawDescGZIP(), []int{0} } func (x *SessionInfo) GetSessionId() string { @@ -228,11 +228,11 @@ func (x *SessionInfo) GetCostUsd() float64 { return 0 } -var File_pluggableharness_session_v1_session_proto protoreflect.FileDescriptor +var File_pluggableharness_session_v1_types_proto protoreflect.FileDescriptor -const file_pluggableharness_session_v1_session_proto_rawDesc = "" + +const file_pluggableharness_session_v1_types_proto_rawDesc = "" + "\n" + - ")pluggableharness/session/v1/session.proto\x12\x1bpluggableharness.session.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x98\x03\n" + + "'pluggableharness/session/v1/types.proto\x12\x1bpluggableharness.session.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x98\x03\n" + "\vSessionInfo\x12\x1d\n" + "\n" + "session_id\x18\x01 \x01(\tR\tsessionId\x12/\n" + @@ -258,25 +258,25 @@ const file_pluggableharness_session_v1_session_proto_rawDesc = "" + "\x15SESSION_STATUS_FAILED\x10\aBBZ@github.com/pluggableharness/agent/pkg/session/proto/v1;sessionv1b\x06proto3" var ( - file_pluggableharness_session_v1_session_proto_rawDescOnce sync.Once - file_pluggableharness_session_v1_session_proto_rawDescData []byte + file_pluggableharness_session_v1_types_proto_rawDescOnce sync.Once + file_pluggableharness_session_v1_types_proto_rawDescData []byte ) -func file_pluggableharness_session_v1_session_proto_rawDescGZIP() []byte { - file_pluggableharness_session_v1_session_proto_rawDescOnce.Do(func() { - file_pluggableharness_session_v1_session_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_session_v1_session_proto_rawDesc), len(file_pluggableharness_session_v1_session_proto_rawDesc))) +func file_pluggableharness_session_v1_types_proto_rawDescGZIP() []byte { + file_pluggableharness_session_v1_types_proto_rawDescOnce.Do(func() { + file_pluggableharness_session_v1_types_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_session_v1_types_proto_rawDesc), len(file_pluggableharness_session_v1_types_proto_rawDesc))) }) - return file_pluggableharness_session_v1_session_proto_rawDescData + return file_pluggableharness_session_v1_types_proto_rawDescData } -var file_pluggableharness_session_v1_session_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_pluggableharness_session_v1_session_proto_msgTypes = make([]protoimpl.MessageInfo, 1) -var file_pluggableharness_session_v1_session_proto_goTypes = []any{ +var file_pluggableharness_session_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_pluggableharness_session_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_pluggableharness_session_v1_types_proto_goTypes = []any{ (SessionStatus)(0), // 0: pluggableharness.session.v1.SessionStatus (*SessionInfo)(nil), // 1: pluggableharness.session.v1.SessionInfo (*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp } -var file_pluggableharness_session_v1_session_proto_depIdxs = []int32{ +var file_pluggableharness_session_v1_types_proto_depIdxs = []int32{ 0, // 0: pluggableharness.session.v1.SessionInfo.status:type_name -> pluggableharness.session.v1.SessionStatus 2, // 1: pluggableharness.session.v1.SessionInfo.started_at:type_name -> google.protobuf.Timestamp 2, // 2: pluggableharness.session.v1.SessionInfo.ended_at:type_name -> google.protobuf.Timestamp @@ -287,28 +287,28 @@ var file_pluggableharness_session_v1_session_proto_depIdxs = []int32{ 0, // [0:3] is the sub-list for field type_name } -func init() { file_pluggableharness_session_v1_session_proto_init() } -func file_pluggableharness_session_v1_session_proto_init() { - if File_pluggableharness_session_v1_session_proto != nil { +func init() { file_pluggableharness_session_v1_types_proto_init() } +func file_pluggableharness_session_v1_types_proto_init() { + if File_pluggableharness_session_v1_types_proto != nil { return } - file_pluggableharness_session_v1_session_proto_msgTypes[0].OneofWrappers = []any{} + file_pluggableharness_session_v1_types_proto_msgTypes[0].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_session_v1_session_proto_rawDesc), len(file_pluggableharness_session_v1_session_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_session_v1_types_proto_rawDesc), len(file_pluggableharness_session_v1_types_proto_rawDesc)), NumEnums: 1, NumMessages: 1, NumExtensions: 0, NumServices: 0, }, - GoTypes: file_pluggableharness_session_v1_session_proto_goTypes, - DependencyIndexes: file_pluggableharness_session_v1_session_proto_depIdxs, - EnumInfos: file_pluggableharness_session_v1_session_proto_enumTypes, - MessageInfos: file_pluggableharness_session_v1_session_proto_msgTypes, + GoTypes: file_pluggableharness_session_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_session_v1_types_proto_depIdxs, + EnumInfos: file_pluggableharness_session_v1_types_proto_enumTypes, + MessageInfos: file_pluggableharness_session_v1_types_proto_msgTypes, }.Build() - File_pluggableharness_session_v1_session_proto = out.File - file_pluggableharness_session_v1_session_proto_goTypes = nil - file_pluggableharness_session_v1_session_proto_depIdxs = nil + File_pluggableharness_session_v1_types_proto = out.File + file_pluggableharness_session_v1_types_proto_goTypes = nil + file_pluggableharness_session_v1_types_proto_depIdxs = nil } diff --git a/pkg/slashcommand/capabilities.go b/pkg/slashcommand/capabilities.go new file mode 100644 index 0000000..6f06024 --- /dev/null +++ b/pkg/slashcommand/capabilities.go @@ -0,0 +1,52 @@ +package slashcommand + +import ( + "context" + "fmt" + + slashcommandv1 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" +) + +// BuildGetCapabilitiesResponse assembles the full GetCapabilitiesResponse +// for p: every command's Spec (via p.Capabilities, converted and validated +// with toProtoSpec), plus this provider's config schema and supported hook +// points when p additionally implements ConfigSchemaProvider or +// HookPointProvider — see slashcommand.go. Unlike pkg/tool's +// BuildGetSchemaResponse, there is no SlashCommandProvider-equivalent +// optional interface here: a SlashCommandSpec has no output_schema and +// this category does not itself declare PromptExpansionSpec entries (see +// docs/specifications/slashcommand/data-types.md#slashcommandspec-vs-promptexpansionspec). +// This package does not re-validate a ConfigSchemaProvider's output; it +// trusts pkg/config's own Attribute/Schema validation, which a +// well-behaved ConfigSchemaProvider implementation is expected to have run +// already. +func BuildGetCapabilitiesResponse(ctx context.Context, p Provider) (*slashcommandv1.GetCapabilitiesResponse, error) { + specs, err := p.Capabilities(ctx) + if err != nil { + return nil, fmt.Errorf("slashcommand: get capabilities: %w", err) + } + + commands := make([]*slashcommandv1.SlashCommandSpec, 0, len(specs)) + for _, s := range specs { + ps, err := toProtoSpec(s) + if err != nil { + return nil, fmt.Errorf("slashcommand: get capabilities: %w", err) + } + commands = append(commands, ps) + } + + resp := &slashcommandv1.GetCapabilitiesResponse{Commands: commands} + + if cs, ok := p.(ConfigSchemaProvider); ok { + schema, err := cs.ConfigSchema() + if err != nil { + return nil, fmt.Errorf("slashcommand: get capabilities: config schema: %w", err) + } + resp.ConfigSchema = schema + } + if hp, ok := p.(HookPointProvider); ok { + resp.SupportedHookPoints = hp.SupportedHookPoints() + } + + return resp, nil +} diff --git a/pkg/slashcommand/capabilities_test.go b/pkg/slashcommand/capabilities_test.go new file mode 100644 index 0000000..cc3f006 --- /dev/null +++ b/pkg/slashcommand/capabilities_test.go @@ -0,0 +1,111 @@ +package slashcommand_test + +import ( + "context" + "errors" + "testing" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + "github.com/pluggableharness/agent/pkg/slashcommand" +) + +func TestBuildGetCapabilitiesResponseBasic(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + capabilitiesFunc: func(context.Context) ([]*slashcommand.Spec, error) { + return []*slashcommand.Spec{validSpec("deploy"), validSpec("release-notes")}, nil + }, + } + + resp, err := slashcommand.BuildGetCapabilitiesResponse(t.Context(), p) + if err != nil { + t.Fatalf("BuildGetCapabilitiesResponse: %v", err) + } + if got := len(resp.GetCommands()); got != 2 { + t.Fatalf("len(Commands) = %d, want 2", got) + } + if resp.GetConfigSchema() != nil { + t.Errorf("ConfigSchema = %v, want nil (provider does not implement ConfigSchemaProvider)", resp.GetConfigSchema()) + } + if resp.GetSupportedHookPoints() != nil { + t.Errorf("SupportedHookPoints = %v, want nil", resp.GetSupportedHookPoints()) + } +} + +func TestBuildGetCapabilitiesResponseCapabilitiesError(t *testing.T) { + t.Parallel() + + wantErr := errors.New("boom") + p := &fakeProvider{ + capabilitiesFunc: func(context.Context) ([]*slashcommand.Spec, error) { return nil, wantErr }, + } + + _, err := slashcommand.BuildGetCapabilitiesResponse(t.Context(), p) + if !errors.Is(err, wantErr) { + t.Fatalf("BuildGetCapabilitiesResponse() error = %v, want wrapping %v", err, wantErr) + } +} + +func TestBuildGetCapabilitiesResponseInvalidSpec(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + capabilitiesFunc: func(context.Context) ([]*slashcommand.Spec, error) { + return []*slashcommand.Spec{{Name: ""}}, nil // missing everything + }, + } + + _, err := slashcommand.BuildGetCapabilitiesResponse(t.Context(), p) + if err == nil { + t.Fatal("BuildGetCapabilitiesResponse() with an invalid Spec: want error, got nil") + } +} + +func TestBuildGetCapabilitiesResponseOptionalCapabilities(t *testing.T) { + t.Parallel() + + base := &fakeProvider{ + capabilitiesFunc: func(context.Context) ([]*slashcommand.Spec, error) { + return []*slashcommand.Spec{validSpec("deploy")}, nil + }, + } + wantSchema := &configv1.ConfigSchema{} + wantHooks := []commonv1.HookPoint{commonv1.HookPoint_HOOK_POINT_PRE_TOOL_CALL} + + p := &fakeFullProvider{ + fakeProvider: base, + configSchemaFunc: func() (*configv1.ConfigSchema, error) { return wantSchema, nil }, + hookPoints: wantHooks, + } + + resp, err := slashcommand.BuildGetCapabilitiesResponse(t.Context(), p) + if err != nil { + t.Fatalf("BuildGetCapabilitiesResponse: %v", err) + } + if resp.GetConfigSchema() != wantSchema { + t.Errorf("ConfigSchema = %v, want %v", resp.GetConfigSchema(), wantSchema) + } + if len(resp.GetSupportedHookPoints()) != 1 || resp.GetSupportedHookPoints()[0] != commonv1.HookPoint_HOOK_POINT_PRE_TOOL_CALL { + t.Errorf("SupportedHookPoints = %v", resp.GetSupportedHookPoints()) + } +} + +func TestBuildGetCapabilitiesResponseConfigSchemaError(t *testing.T) { + t.Parallel() + + wantErr := errors.New("bad config schema") + base := &fakeProvider{ + capabilitiesFunc: func(context.Context) ([]*slashcommand.Spec, error) { return nil, nil }, + } + p := &fakeFullProvider{ + fakeProvider: base, + configSchemaFunc: func() (*configv1.ConfigSchema, error) { return nil, wantErr }, + } + + _, err := slashcommand.BuildGetCapabilitiesResponse(t.Context(), p) + if !errors.Is(err, wantErr) { + t.Fatalf("BuildGetCapabilitiesResponse() error = %v, want wrapping %v", err, wantErr) + } +} diff --git a/pkg/slashcommand/convert.go b/pkg/slashcommand/convert.go new file mode 100644 index 0000000..060a3e1 --- /dev/null +++ b/pkg/slashcommand/convert.go @@ -0,0 +1,345 @@ +package slashcommand + +import ( + "errors" + "fmt" + + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/structpb" + + slashcommandv1 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" + "github.com/pluggableharness/agent/pkg/tool" + toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" +) + +// Sentinel errors returned by the domain<->proto conversions in this file. +var ( + // ErrNilSpec is returned when converting a nil *Spec. + ErrNilSpec = errors.New("slashcommand: spec must not be nil") + // ErrNilCall is returned when converting a nil *slashcommandv1.SlashCommandCall. + ErrNilCall = errors.New("slashcommand: call must not be nil") + // ErrNilEvent is returned by Stream.Send and toProtoEvent for a nil + // *Event. + ErrNilEvent = errors.New("slashcommand: event must not be nil") + // ErrEventFieldCount is returned when an Event does not have exactly + // one of its six fields set. + ErrEventFieldCount = errors.New("slashcommand: event must set exactly one field") + + // ErrEmptyName is returned when a Spec's Name is empty. + ErrEmptyName = errors.New("slashcommand: name must not be empty") + // ErrUnspecifiedKind is returned when a Spec's Kind is + // tool.KindUnspecified. + ErrUnspecifiedKind = errors.New("slashcommand: kind must not be unspecified") + // ErrEmptyDescription is returned when a Spec's Description is + // empty. + ErrEmptyDescription = errors.New("slashcommand: description must not be empty") + // ErrNilInputSchema is returned when a Spec's InputSchema is nil. + ErrNilInputSchema = errors.New("slashcommand: input_schema must not be nil") + // ErrInvalidRiskForKind is returned when a Spec's Risk does not + // match what its Kind requires — tool.RiskClassReadOnly for + // tool.KindDataSource/tool.KindInteractive, one of + // low/moderate/high/critical for tool.KindResource. + ErrInvalidRiskForKind = errors.New("slashcommand: risk does not match kind's required risk classification") + // ErrConcurrencyRequired is returned when a Spec's Concurrency is + // nil for a kind other than tool.KindInteractive. + ErrConcurrencyRequired = errors.New("slashcommand: concurrency must be set except for kind interactive") + // ErrConcurrencyForbiddenForInteractive is returned when a + // tool.KindInteractive Spec declares a non-nil Concurrency, per + // docs/specifications/slashcommand/protocol.md#getcapabilities and + // tool/protocol.md#kind-interactive's "MUST NOT declare a + // ConcurrencySpec" rule. This package's judgment call, matching + // pkg/tool's own, is to reject the construction outright rather + // than silently stripping it. + ErrConcurrencyForbiddenForInteractive = errors.New("slashcommand: concurrency must not be declared for kind interactive") +) + +// toProtoKind converts k to its wire representation — the literal +// pluggableharness.tool.v1.ToolKind, reused verbatim per +// docs/specifications/slashcommand/data-types.md#reused-toolv1-types. +// pkg/tool's own equivalent is unexported, so this package carries its own +// copy of the conversion logic. +func toProtoKind(k tool.Kind) toolv1.ToolKind { + switch k { + case tool.KindResource: + return toolv1.ToolKind_TOOL_KIND_RESOURCE + case tool.KindDataSource: + return toolv1.ToolKind_TOOL_KIND_DATA_SOURCE + case tool.KindInteractive: + return toolv1.ToolKind_TOOL_KIND_INTERACTIVE + default: + return toolv1.ToolKind_TOOL_KIND_UNSPECIFIED + } +} + +// toProtoRiskClass converts r to its wire representation — the literal +// pluggableharness.tool.v1.RiskClass, reused verbatim. +func toProtoRiskClass(r tool.RiskClass) toolv1.RiskClass { + switch r { + case tool.RiskClassReadOnly: + return toolv1.RiskClass_RISK_CLASS_READ_ONLY + case tool.RiskClassLow: + return toolv1.RiskClass_RISK_CLASS_LOW + case tool.RiskClassModerate: + return toolv1.RiskClass_RISK_CLASS_MODERATE + case tool.RiskClassHigh: + return toolv1.RiskClass_RISK_CLASS_HIGH + case tool.RiskClassCritical: + return toolv1.RiskClass_RISK_CLASS_CRITICAL + default: + return toolv1.RiskClass_RISK_CLASS_UNSPECIFIED + } +} + +// toProtoOutputStream converts s to its wire representation — the literal +// pluggableharness.tool.v1.OutputStream, reused verbatim. +func toProtoOutputStream(s tool.OutputStream) toolv1.OutputStream { + switch s { + case tool.OutputStreamStdout: + return toolv1.OutputStream_OUTPUT_STREAM_STDOUT + case tool.OutputStreamStderr: + return toolv1.OutputStream_OUTPUT_STREAM_STDERR + default: + return toolv1.OutputStream_OUTPUT_STREAM_UNSPECIFIED + } +} + +// toProtoConcurrencySpec converts c to its wire representation — the +// literal pluggableharness.tool.v1.ConcurrencySpec, reused verbatim. A nil +// c converts to nil. +func toProtoConcurrencySpec(c *tool.ConcurrencySpec) *toolv1.ConcurrencySpec { + if c == nil { + return nil + } + return &toolv1.ConcurrencySpec{Safe: c.Safe, KeyFields: c.KeyFields} +} + +// validateSpec checks the MUST-level invariants +// docs/specifications/slashcommand/protocol.md#getcapabilities and +// docs/specifications/slashcommand/data-types.md#slashcommandspec place on +// a Spec — the same kind/risk/concurrency rules tool.Schema carries, +// applied verbatim per data-types.md, minus the OutputSchema check (this +// category declares no output_schema at all). +func validateSpec(s *Spec) error { + if s.Name == "" { + return ErrEmptyName + } + if s.Kind == tool.KindUnspecified { + return ErrUnspecifiedKind + } + if s.Description == "" { + return ErrEmptyDescription + } + if s.InputSchema == nil { + return ErrNilInputSchema + } + + switch s.Kind { + case tool.KindDataSource, tool.KindInteractive: + if s.Risk != tool.RiskClassReadOnly { + return fmt.Errorf("%w: %s requires read_only, got %s", ErrInvalidRiskForKind, s.Kind, s.Risk) + } + case tool.KindResource: + switch s.Risk { + case tool.RiskClassLow, tool.RiskClassModerate, tool.RiskClassHigh, tool.RiskClassCritical: + default: + return fmt.Errorf("%w: resource requires one of low/moderate/high/critical, got %s", ErrInvalidRiskForKind, s.Risk) + } + } + + if s.Kind == tool.KindInteractive { + if s.Concurrency != nil { + return ErrConcurrencyForbiddenForInteractive + } + } else if s.Concurrency == nil { + return ErrConcurrencyRequired + } + + return nil +} + +// toProtoSpec validates s and converts it to its wire representation. +func toProtoSpec(s *Spec) (*slashcommandv1.SlashCommandSpec, error) { + if s == nil { + return nil, ErrNilSpec + } + if err := validateSpec(s); err != nil { + return nil, fmt.Errorf("slashcommand: spec %q: %w", s.Name, err) + } + + ps := &slashcommandv1.SlashCommandSpec{ + Name: s.Name, + Description: s.Description, + InputSchema: s.InputSchema, + Kind: toProtoKind(s.Kind), + Risk: toProtoRiskClass(s.Risk), + Concurrency: toProtoConcurrencySpec(s.Concurrency), + Streaming: s.Streaming, + Idempotent: s.Idempotent, + } + if s.DefaultTimeout > 0 { + ps.DefaultTimeout = durationpb.New(s.DefaultTimeout) + } + return ps, nil +} + +// toProtoToolResult converts r to its wire representation — the literal +// pluggableharness.tool.v1.ToolResult, reused verbatim as +// SlashCommandEvent.result. +func toProtoToolResult(r *tool.Result) (*toolv1.ToolResult, error) { + if r == nil { + return nil, tool.ErrNilResult + } + payload, err := mapToStruct(r.Payload) + if err != nil { + return nil, fmt.Errorf("slashcommand: tool result: %w", err) + } + return &toolv1.ToolResult{Payload: payload}, nil +} + +// toProtoToolError converts e to its wire representation — the literal +// pluggableharness.tool.v1.ToolError, reused verbatim as +// SlashCommandEvent.error. Re-validates e's fields by round-tripping +// through tool.NewError rather than duplicating pkg/tool's own (unexported) +// category-validation logic — the one piece of tool.go's error-construction +// behavior this package genuinely reuses at the call site instead of +// re-implementing. +func toProtoToolError(e *tool.Error) (*toolv1.ToolError, error) { + if e == nil { + return nil, tool.ErrNilError + } + if _, err := tool.NewError(e.Category, e.Message, e.Retryable, e.Details); err != nil { + return nil, fmt.Errorf("slashcommand: tool error: %w", err) + } + + pe := &toolv1.ToolError{ + Category: toProtoErrorCategory(e.Category), + Message: e.Message, + Retryable: e.Retryable, + } + if len(e.Details) > 0 { + details, err := mapToStruct(e.Details) + if err != nil { + return nil, fmt.Errorf("slashcommand: tool error: details: %w", err) + } + pe.Details = details + } + return pe, nil +} + +// toProtoErrorCategory converts c to its wire representation. pkg/tool's +// own equivalent is unexported, so this package carries its own copy — +// same reasoning as toProtoKind above. +func toProtoErrorCategory(c tool.ErrorCategory) toolv1.ToolErrorCategory { + switch c { + case tool.ErrorCategoryInvalidArguments: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_INVALID_ARGUMENTS + case tool.ErrorCategoryNotFound: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_NOT_FOUND + case tool.ErrorCategoryPermissionDenied: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_PERMISSION_DENIED + case tool.ErrorCategoryExecutionFailed: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_EXECUTION_FAILED + case tool.ErrorCategoryTimeout: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_TIMEOUT + case tool.ErrorCategoryConcurrencyConflict: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_CONCURRENCY_CONFLICT + case tool.ErrorCategoryCancelled: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_CANCELLED + case tool.ErrorCategoryUnknown: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_UNKNOWN + default: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_UNSPECIFIED + } +} + +// toProtoEvent converts e to its wire representation, rejecting a nil +// event or one that does not set exactly one field — the same +// "exactly-one-of-six" shape +// docs/specifications/slashcommand/data-types.md#slashcommandcall--slashcommandevent +// describes for the underlying oneof, reused verbatim from +// tool/data-types.md#toolcall--toolevent--toolresult. +func toProtoEvent(e *Event) (*slashcommandv1.SlashCommandEvent, error) { + if e == nil { + return nil, ErrNilEvent + } + + set := 0 + for _, isSet := range []bool{e.OutputChunk != nil, e.Progress != nil, e.PartialResult != nil, e.ExitStatus != nil, e.Result != nil, e.Error != nil} { + if isSet { + set++ + } + } + if set != 1 { + return nil, fmt.Errorf("slashcommand: event: %w: got %d fields set", ErrEventFieldCount, set) + } + + switch { + case e.OutputChunk != nil: + return &slashcommandv1.SlashCommandEvent{Event: &slashcommandv1.SlashCommandEvent_OutputChunk_{OutputChunk: &slashcommandv1.SlashCommandEvent_OutputChunk{ + Stream: toProtoOutputStream(e.OutputChunk.Stream), + Data: e.OutputChunk.Data, + }}}, nil + case e.Progress != nil: + return &slashcommandv1.SlashCommandEvent{Event: &slashcommandv1.SlashCommandEvent_Progress_{Progress: &slashcommandv1.SlashCommandEvent_Progress{ + Message: e.Progress.Message, + FractionComplete: e.Progress.FractionComplete, + }}}, nil + case e.PartialResult != nil: + payload, err := mapToStruct(e.PartialResult.Payload) + if err != nil { + return nil, fmt.Errorf("slashcommand: event: partial_result: %w", err) + } + return &slashcommandv1.SlashCommandEvent{Event: &slashcommandv1.SlashCommandEvent_PartialResult_{PartialResult: &slashcommandv1.SlashCommandEvent_PartialResult{Payload: payload}}}, nil + case e.ExitStatus != nil: + return &slashcommandv1.SlashCommandEvent{Event: &slashcommandv1.SlashCommandEvent_ExitStatus_{ExitStatus: &slashcommandv1.SlashCommandEvent_ExitStatus{ + ExitCode: e.ExitStatus.ExitCode, + Signal: e.ExitStatus.Signal, + }}}, nil + case e.Result != nil: + pr, err := toProtoToolResult(e.Result) + if err != nil { + return nil, fmt.Errorf("slashcommand: event: %w", err) + } + return &slashcommandv1.SlashCommandEvent{Event: &slashcommandv1.SlashCommandEvent_Result{Result: pr}}, nil + default: // e.Error != nil, guaranteed by the exactly-one-field check above. + pe, err := toProtoToolError(e.Error) + if err != nil { + return nil, fmt.Errorf("slashcommand: event: %w", err) + } + return &slashcommandv1.SlashCommandEvent{Event: &slashcommandv1.SlashCommandEvent_Error{Error: pe}}, nil + } +} + +// fromProtoCall converts c from its wire representation. +func fromProtoCall(c *slashcommandv1.SlashCommandCall) (*Call, error) { + if c == nil { + return nil, ErrNilCall + } + return &Call{ + ID: c.GetId(), + Name: c.GetName(), + Arguments: structToMap(c.GetArguments()), + CallContext: c.GetCallContext(), + }, nil +} + +// structToMap converts s to a plain map, or nil if s is nil. +// structpb.Struct.AsMap never errors. +func structToMap(s *structpb.Struct) map[string]any { + if s == nil { + return nil + } + return s.AsMap() +} + +// mapToStruct converts m to a *structpb.Struct, or nil if m is empty. +func mapToStruct(m map[string]any) (*structpb.Struct, error) { + if len(m) == 0 { + return nil, nil //nolint:nilnil // absence of a payload is a meaningful, documented zero value on the wire (an unset embedded message field), not an ambiguous "no result, no error". + } + s, err := structpb.NewStruct(m) + if err != nil { + return nil, fmt.Errorf("slashcommand: encode struct: %w", err) + } + return s, nil +} diff --git a/pkg/slashcommand/convert_test.go b/pkg/slashcommand/convert_test.go new file mode 100644 index 0000000..d0fa062 --- /dev/null +++ b/pkg/slashcommand/convert_test.go @@ -0,0 +1,447 @@ +package slashcommand + +import ( + "errors" + "testing" + "time" + + "google.golang.org/protobuf/types/known/structpb" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + schemav1 "github.com/pluggableharness/agent/pkg/schema/proto/v1" + slashcommandv1 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" + "github.com/pluggableharness/agent/pkg/tool" + toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" +) + +func validTestSpec(name string) *Spec { + return &Spec{ + Name: name, + Description: "a test command", + InputSchema: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_OBJECT}, + Kind: tool.KindDataSource, + Risk: tool.RiskClassReadOnly, + Concurrency: &tool.ConcurrencySpec{Safe: true}, + } +} + +func TestToProtoKind(t *testing.T) { + t.Parallel() + + tests := []struct { + in tool.Kind + want toolv1.ToolKind + }{ + {tool.KindResource, toolv1.ToolKind_TOOL_KIND_RESOURCE}, + {tool.KindDataSource, toolv1.ToolKind_TOOL_KIND_DATA_SOURCE}, + {tool.KindInteractive, toolv1.ToolKind_TOOL_KIND_INTERACTIVE}, + {tool.KindUnspecified, toolv1.ToolKind_TOOL_KIND_UNSPECIFIED}, + {tool.Kind(99), toolv1.ToolKind_TOOL_KIND_UNSPECIFIED}, + } + for _, tt := range tests { + if got := toProtoKind(tt.in); got != tt.want { + t.Errorf("toProtoKind(%v) = %v, want %v", tt.in, got, tt.want) + } + } +} + +func TestToProtoRiskClass(t *testing.T) { + t.Parallel() + + tests := []struct { + in tool.RiskClass + want toolv1.RiskClass + }{ + {tool.RiskClassReadOnly, toolv1.RiskClass_RISK_CLASS_READ_ONLY}, + {tool.RiskClassLow, toolv1.RiskClass_RISK_CLASS_LOW}, + {tool.RiskClassModerate, toolv1.RiskClass_RISK_CLASS_MODERATE}, + {tool.RiskClassHigh, toolv1.RiskClass_RISK_CLASS_HIGH}, + {tool.RiskClassCritical, toolv1.RiskClass_RISK_CLASS_CRITICAL}, + {tool.RiskClassUnspecified, toolv1.RiskClass_RISK_CLASS_UNSPECIFIED}, + {tool.RiskClass(99), toolv1.RiskClass_RISK_CLASS_UNSPECIFIED}, + } + for _, tt := range tests { + if got := toProtoRiskClass(tt.in); got != tt.want { + t.Errorf("toProtoRiskClass(%v) = %v, want %v", tt.in, got, tt.want) + } + } +} + +func TestToProtoOutputStream(t *testing.T) { + t.Parallel() + + tests := []struct { + in tool.OutputStream + want toolv1.OutputStream + }{ + {tool.OutputStreamStdout, toolv1.OutputStream_OUTPUT_STREAM_STDOUT}, + {tool.OutputStreamStderr, toolv1.OutputStream_OUTPUT_STREAM_STDERR}, + {tool.OutputStreamUnspecified, toolv1.OutputStream_OUTPUT_STREAM_UNSPECIFIED}, + {tool.OutputStream(99), toolv1.OutputStream_OUTPUT_STREAM_UNSPECIFIED}, + } + for _, tt := range tests { + if got := toProtoOutputStream(tt.in); got != tt.want { + t.Errorf("toProtoOutputStream(%v) = %v, want %v", tt.in, got, tt.want) + } + } +} + +func TestToProtoConcurrencySpec(t *testing.T) { + t.Parallel() + + if got := toProtoConcurrencySpec(nil); got != nil { + t.Errorf("toProtoConcurrencySpec(nil) = %v, want nil", got) + } + + got := toProtoConcurrencySpec(&tool.ConcurrencySpec{Safe: true, KeyFields: []string{"path"}}) + if !got.GetSafe() || len(got.GetKeyFields()) != 1 || got.GetKeyFields()[0] != "path" { + t.Errorf("toProtoConcurrencySpec(...) = %v", got) + } +} + +func TestValidateSpec(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*Spec) + wantErr error + }{ + {"valid data_source", func(*Spec) {}, nil}, + {"empty name", func(s *Spec) { s.Name = "" }, ErrEmptyName}, + {"unspecified kind", func(s *Spec) { s.Kind = tool.KindUnspecified }, ErrUnspecifiedKind}, + {"empty description", func(s *Spec) { s.Description = "" }, ErrEmptyDescription}, + {"nil input schema", func(s *Spec) { s.InputSchema = nil }, ErrNilInputSchema}, + {"data_source wrong risk", func(s *Spec) { s.Risk = tool.RiskClassLow }, ErrInvalidRiskForKind}, + { + "resource wrong risk", + func(s *Spec) { + s.Kind = tool.KindResource + s.Risk = tool.RiskClassReadOnly + }, + ErrInvalidRiskForKind, + }, + { + "resource missing concurrency", + func(s *Spec) { + s.Kind = tool.KindResource + s.Risk = tool.RiskClassHigh + s.Concurrency = nil + }, + ErrConcurrencyRequired, + }, + { + "interactive with concurrency", + func(s *Spec) { + s.Kind = tool.KindInteractive + s.Risk = tool.RiskClassReadOnly + }, + ErrConcurrencyForbiddenForInteractive, + }, + { + "interactive without concurrency ok", + func(s *Spec) { + s.Kind = tool.KindInteractive + s.Risk = tool.RiskClassReadOnly + s.Concurrency = nil + }, + nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := validTestSpec("cmd") + tt.mutate(s) + err := validateSpec(s) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("validateSpec() = %v, want nil", err) + } + return + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("validateSpec() = %v, want wrapping %v", err, tt.wantErr) + } + }) + } +} + +func TestToProtoSpec(t *testing.T) { + t.Parallel() + + if _, err := toProtoSpec(nil); !errors.Is(err, ErrNilSpec) { + t.Fatalf("toProtoSpec(nil) error = %v, want wrapping %v", err, ErrNilSpec) + } + + s := validTestSpec("deploy") + s.Streaming = true + s.Idempotent = true + s.DefaultTimeout = 5 * time.Second + + got, err := toProtoSpec(s) + if err != nil { + t.Fatalf("toProtoSpec: %v", err) + } + if got.GetName() != "deploy" || !got.GetStreaming() || !got.GetIdempotent() { + t.Errorf("toProtoSpec(...) = %v", got) + } + if got.GetDefaultTimeout().AsDuration() != 5*time.Second { + t.Errorf("DefaultTimeout = %v, want 5s", got.GetDefaultTimeout().AsDuration()) + } + if got.GetKind() != toolv1.ToolKind_TOOL_KIND_DATA_SOURCE { + t.Errorf("Kind = %v, want TOOL_KIND_DATA_SOURCE", got.GetKind()) + } + + invalid := &Spec{} + if _, err := toProtoSpec(invalid); err == nil { + t.Error("toProtoSpec(invalid) = nil error, want error") + } +} + +func TestToProtoSpecNoDefaultTimeoutWhenZero(t *testing.T) { + t.Parallel() + + s := validTestSpec("deploy") + got, err := toProtoSpec(s) + if err != nil { + t.Fatalf("toProtoSpec: %v", err) + } + if got.GetDefaultTimeout() != nil { + t.Errorf("DefaultTimeout = %v, want nil (unset DefaultTimeout)", got.GetDefaultTimeout()) + } +} + +func TestToProtoToolResult(t *testing.T) { + t.Parallel() + + if _, err := toProtoToolResult(nil); !errors.Is(err, tool.ErrNilResult) { + t.Fatalf("toProtoToolResult(nil) error = %v, want wrapping %v", err, tool.ErrNilResult) + } + + got, err := toProtoToolResult(&tool.Result{Payload: map[string]any{"ok": true}}) + if err != nil { + t.Fatalf("toProtoToolResult: %v", err) + } + if !got.GetPayload().AsMap()["ok"].(bool) { + t.Errorf("Payload = %v", got.GetPayload()) + } + + empty, err := toProtoToolResult(&tool.Result{}) + if err != nil { + t.Fatalf("toProtoToolResult(empty): %v", err) + } + if empty.GetPayload() != nil { + t.Errorf("Payload = %v, want nil for an empty result", empty.GetPayload()) + } +} + +func TestToProtoToolError(t *testing.T) { + t.Parallel() + + if _, err := toProtoToolError(nil); !errors.Is(err, tool.ErrNilError) { + t.Fatalf("toProtoToolError(nil) error = %v, want wrapping %v", err, tool.ErrNilError) + } + + got, err := toProtoToolError(&tool.Error{Category: tool.ErrorCategoryNotFound, Message: "missing", Retryable: true, Details: map[string]any{"path": "/x"}}) + if err != nil { + t.Fatalf("toProtoToolError: %v", err) + } + if got.GetCategory() != toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_NOT_FOUND || got.GetMessage() != "missing" || !got.GetRetryable() { + t.Errorf("toProtoToolError(...) = %v", got) + } + if got.GetDetails().AsMap()["path"] != "/x" { + t.Errorf("Details = %v", got.GetDetails()) + } + + // Invalid category (unspecified) is rejected by the round trip + // through tool.NewError. + if _, err := toProtoToolError(&tool.Error{Category: tool.ErrorCategoryUnspecified, Message: "x"}); err == nil { + t.Error("toProtoToolError(unspecified category) = nil error, want error") + } + // Empty message is likewise rejected. + if _, err := toProtoToolError(&tool.Error{Category: tool.ErrorCategoryUnknown, Message: ""}); err == nil { + t.Error("toProtoToolError(empty message) = nil error, want error") + } +} + +func TestToProtoEvent(t *testing.T) { + t.Parallel() + + if _, err := toProtoEvent(nil); !errors.Is(err, ErrNilEvent) { + t.Fatalf("toProtoEvent(nil) error = %v, want wrapping %v", err, ErrNilEvent) + } + + if _, err := toProtoEvent(&Event{}); !errors.Is(err, ErrEventFieldCount) { + t.Fatalf("toProtoEvent(zero-value) error = %v, want wrapping %v", err, ErrEventFieldCount) + } + + twoSet := NewResultEvent(nil) + twoSet.Error = &tool.Error{Category: tool.ErrorCategoryUnknown, Message: "x"} + if _, err := toProtoEvent(twoSet); !errors.Is(err, ErrEventFieldCount) { + t.Fatalf("toProtoEvent(two fields set) error = %v, want wrapping %v", err, ErrEventFieldCount) + } + + half := 0.5 + tests := []struct { + name string + event *Event + check func(t *testing.T, got *slashcommandv1.SlashCommandEvent) + }{ + { + "output_chunk", + NewOutputChunkEvent(tool.OutputStreamStdout, []byte("hi")), + func(t *testing.T, got *slashcommandv1.SlashCommandEvent) { + t.Helper() + if got.GetOutputChunk() == nil || string(got.GetOutputChunk().GetData()) != "hi" { + t.Errorf("output_chunk = %v", got.GetOutputChunk()) + } + }, + }, + { + "progress", + NewProgressEvent("working", &half), + func(t *testing.T, got *slashcommandv1.SlashCommandEvent) { + t.Helper() + if got.GetProgress() == nil || got.GetProgress().GetFractionComplete() != 0.5 { + t.Errorf("progress = %v", got.GetProgress()) + } + }, + }, + { + "partial_result", + NewPartialResultEvent(map[string]any{"n": 1.0}), + func(t *testing.T, got *slashcommandv1.SlashCommandEvent) { + t.Helper() + if got.GetPartialResult() == nil || got.GetPartialResult().GetPayload().AsMap()["n"] != 1.0 { + t.Errorf("partial_result = %v", got.GetPartialResult()) + } + }, + }, + { + "exit_status", + NewExitStatusEvent(1, nil), + func(t *testing.T, got *slashcommandv1.SlashCommandEvent) { + t.Helper() + if got.GetExitStatus() == nil || got.GetExitStatus().GetExitCode() != 1 { + t.Errorf("exit_status = %v", got.GetExitStatus()) + } + }, + }, + { + "result", + NewResultEvent(map[string]any{"ok": true}), + func(t *testing.T, got *slashcommandv1.SlashCommandEvent) { + t.Helper() + if got.GetResult() == nil || !got.GetResult().GetPayload().AsMap()["ok"].(bool) { + t.Errorf("result = %v", got.GetResult()) + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := toProtoEvent(tt.event) + if err != nil { + t.Fatalf("toProtoEvent: %v", err) + } + tt.check(t, got) + }) + } + + t.Run("error", func(t *testing.T) { + t.Parallel() + te, err := tool.NewError(tool.ErrorCategoryNotFound, "gone", false, nil) + if err != nil { + t.Fatalf("tool.NewError: %v", err) + } + got, err := toProtoEvent(NewErrorEvent(te)) + if err != nil { + t.Fatalf("toProtoEvent: %v", err) + } + if got.GetError() == nil || got.GetError().GetMessage() != "gone" { + t.Errorf("error = %v", got.GetError()) + } + }) + + t.Run("partial_result conversion failure propagates", func(t *testing.T) { + t.Parallel() + _, err := toProtoEvent(NewPartialResultEvent(map[string]any{"bad": make(chan int)})) + if err == nil { + t.Error("toProtoEvent(partial_result with unencodable payload) = nil error, want error") + } + }) + + t.Run("result conversion failure propagates", func(t *testing.T) { + t.Parallel() + _, err := toProtoEvent(NewResultEvent(map[string]any{"bad": make(chan int)})) + if err == nil { + t.Error("toProtoEvent(result with unencodable payload) = nil error, want error") + } + }) + + t.Run("error conversion failure propagates", func(t *testing.T) { + t.Parallel() + _, err := toProtoEvent(NewErrorEvent(&tool.Error{Category: tool.ErrorCategoryUnspecified, Message: "x"})) + if err == nil { + t.Error("toProtoEvent(invalid error) = nil error, want error") + } + }) +} + +func TestFromProtoCall(t *testing.T) { + t.Parallel() + + if _, err := fromProtoCall(nil); !errors.Is(err, ErrNilCall) { + t.Fatalf("fromProtoCall(nil) error = %v, want wrapping %v", err, ErrNilCall) + } + + args, err := structpb.NewStruct(map[string]any{"env": "prod"}) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + wireCall := &slashcommandv1.SlashCommandCall{ + Id: "c1", + Name: "deploy", + Arguments: args, + CallContext: &commonv1.CallContext{SessionId: "s1", TurnId: "t1", WorkingDirectory: "/work"}, + } + + got, err := fromProtoCall(wireCall) + if err != nil { + t.Fatalf("fromProtoCall: %v", err) + } + if got.ID != "c1" || got.Name != "deploy" || got.Arguments["env"] != "prod" { + t.Errorf("fromProtoCall(...) = %+v", got) + } + if got.CallContext.GetSessionId() != "s1" { + t.Errorf("CallContext = %v", got.CallContext) + } +} + +func TestStructToMapNil(t *testing.T) { + t.Parallel() + if got := structToMap(nil); got != nil { + t.Errorf("structToMap(nil) = %v, want nil", got) + } +} + +func TestMapToStructEmpty(t *testing.T) { + t.Parallel() + got, err := mapToStruct(nil) + if err != nil { + t.Fatalf("mapToStruct(nil): %v", err) + } + if got != nil { + t.Errorf("mapToStruct(nil) = %v, want nil", got) + } +} + +func TestMapToStructError(t *testing.T) { + t.Parallel() + if _, err := mapToStruct(map[string]any{"bad": make(chan int)}); err == nil { + t.Error("mapToStruct(unencodable) = nil error, want error") + } +} diff --git a/pkg/slashcommand/doc.go b/pkg/slashcommand/doc.go new file mode 100644 index 0000000..d0ca566 --- /dev/null +++ b/pkg/slashcommand/doc.go @@ -0,0 +1,47 @@ +// Package slashcommand implements the hand-written, plugin-author-facing +// Go SDK for the slash-command provider category — a plugin that declares +// and directly executes one or more slash commands in its own right +// (/deploy, /release-notes, ...), rather than merely expanding a static +// prompt template (docs/specifications/slashcommand/README.md). It sits +// directly on top of the generated pkg/slashcommand/proto/v1 stubs +// (slashcommandv1) and the shared foundation packages (pkg/plugin, +// pkg/config, pkg/schema, pkg/render): a plugin author implements +// Provider, builds a *Service with NewService, and passes it to +// plugin.Config.Services before calling plugin.Serve. +// +// See docs/specifications/slashcommand/protocol.md for the six RPCs this +// package wires up (GetCapabilities, Configure, Invoke, Render, Preview, +// Describe — the first two plus Invoke and Describe MUST be implemented by +// every provider; Render and Preview MAY), +// docs/specifications/slashcommand/data-types.md for the +// Spec / Call / Event shapes, and +// docs/specifications/slashcommand/conformance.md for the reused error +// taxonomy and the full MUST/SHOULD/MAY summary matrix this package +// enforces where it can. +// +// # Types reused verbatim from pkg/tool +// +// docs/specifications/slashcommand/data-types.md#reused-toolv1-types +// mandates that this category reuse six pluggableharness.tool.v1 types +// VERBATIM, with no parallel redeclaration under +// pluggableharness.slashcommand.v1: ToolKind, RiskClass, ConcurrencySpec, +// ToolResult, ToolError, and OutputStream — their Go-domain-level +// counterparts used throughout this package's code are tool.Kind, +// tool.RiskClass, tool.ConcurrencySpec, tool.Result, tool.Error, and +// tool.OutputStream, imported directly from +// github.com/pluggableharness/agent/pkg/tool. This is not incidental +// convergence: a direct-invoke command flows through the identical +// plan/apply gate a tool call does, so it needs the identical +// classification vocabulary. On the wire, the generated +// SlashCommandSpec.kind/risk/concurrency fields and +// SlashCommandEvent.result/error/OutputChunk.stream fields are typed +// directly as the pluggableharness.tool.v1 message/enum — see +// pkg/slashcommand/proto/v1/types.pb.go and events.pb.go — not as any +// slashcommand.v1-local equivalent. +// +// A future reader MUST NOT add a "SlashCommandKind", "SlashCommandRisk", +// "SlashCommandResult", "SlashCommandError", or similar type to this +// package out of habit — extend pkg/tool instead if one of the six ever +// needs to change shape, and update every consumer (including this +// package) in lockstep. +package slashcommand diff --git a/pkg/slashcommand/errors.go b/pkg/slashcommand/errors.go new file mode 100644 index 0000000..a998486 --- /dev/null +++ b/pkg/slashcommand/errors.go @@ -0,0 +1,51 @@ +package slashcommand + +import ( + "errors" + "fmt" + + "github.com/pluggableharness/agent/pkg/tool" +) + +// This package declares no SlashCommandError or SlashCommandErrorCategory +// of its own — docs/specifications/slashcommand/conformance.md#error-taxonomy +// is explicit that SlashCommandEvent.error is a +// pluggableharness.tool.v1.ToolError reused verbatim, so its failure +// taxonomy is tool.ErrorCategory, unmodified, and "MUST NOT invent a +// parallel category." Every error server.go's RPC handlers return crosses +// the plugin boundary via tool.NewError/tool.GRPCCode/tool.ToStatusError +// directly; the helpers below only factor out the small, repeated shapes +// server.go's five non-streaming-terminal-event RPC handlers each need, +// rather than inlining the same tool.Error{...} literal five times. + +// unknownStatusError wraps err as a tool.ErrorCategoryUnknown *tool.Error +// and converts it to a gRPC status via tool.ToStatusError — the catch-all +// shape a handler uses for an unexpected error a Provider method returned +// with no more specific classification available. +func unknownStatusError(err error) error { + return tool.ToStatusError(&tool.Error{Category: tool.ErrorCategoryUnknown, Message: err.Error(), Retryable: false}) +} + +// configureStatusError converts a Provider.Configure error into a gRPC +// status: a *tool.Error is forwarded as-is (its own category, message, and +// retryability preserved), any other error defaults to +// tool.ErrorCategoryInvalidArguments per +// docs/specifications/slashcommand/protocol.md#configure's "MUST reject +// with a structured error on missing required fields rather than +// deferring failure to the first Invoke." +func configureStatusError(err error) error { + var te *tool.Error + if errors.As(err, &te) { + return tool.ToStatusError(te) + } + return tool.ToStatusError(&tool.Error{Category: tool.ErrorCategoryInvalidArguments, Message: err.Error(), Retryable: false}) +} + +// invalidArgumentStatusError builds a gRPC status for a malformed request +// message (e.g. a nil SlashCommandCall) — tool.ErrorCategoryInvalidArguments, +// distinct from configureStatusError in that this is never a +// Provider-returned error, only a wire-decoding failure this package's own +// adapter code detects. +func invalidArgumentStatusError(format string, args ...any) error { + return tool.ToStatusError(&tool.Error{Category: tool.ErrorCategoryInvalidArguments, Message: fmt.Sprintf(format, args...), Retryable: false}) +} diff --git a/pkg/slashcommand/errors_test.go b/pkg/slashcommand/errors_test.go new file mode 100644 index 0000000..4b8a489 --- /dev/null +++ b/pkg/slashcommand/errors_test.go @@ -0,0 +1,71 @@ +package slashcommand_test + +import ( + "errors" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/pluggableharness/agent/pkg/tool" +) + +// TestErrorRoundTripThroughToolPackage exercises this package's call sites +// (errors.go, server.go) against tool.NewError/tool.GRPCCode/ +// tool.ToStatusError directly, per the "route every error through pkg/tool +// directly, no parallel error type" mandate — the round trip itself is +// exactly the behavior server_test.go's Configure/Invoke tests already +// exercise over the wire; this test isolates the tool.NewError -> +// tool.ToStatusError leg pkg/slashcommand's own code never wraps in +// anything of its own. +func TestErrorRoundTripThroughToolPackage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + category tool.ErrorCategory + wantCode codes.Code + }{ + {"invalid_arguments", tool.ErrorCategoryInvalidArguments, codes.InvalidArgument}, + {"not_found", tool.ErrorCategoryNotFound, codes.NotFound}, + {"permission_denied", tool.ErrorCategoryPermissionDenied, codes.PermissionDenied}, + {"execution_failed", tool.ErrorCategoryExecutionFailed, codes.Internal}, + {"timeout", tool.ErrorCategoryTimeout, codes.DeadlineExceeded}, + {"concurrency_conflict", tool.ErrorCategoryConcurrencyConflict, codes.Aborted}, + {"cancelled", tool.ErrorCategoryCancelled, codes.Canceled}, + {"unknown", tool.ErrorCategoryUnknown, codes.Internal}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + te, err := tool.NewError(tt.category, "boom", false, nil) + if err != nil { + t.Fatalf("tool.NewError(%v, ...): %v", tt.category, err) + } + gotErr := tool.ToStatusError(te) + st, ok := status.FromError(gotErr) + if !ok { + t.Fatalf("tool.ToStatusError(%v) did not produce a *status.Status: %v", te, gotErr) + } + if st.Code() != tt.wantCode { + t.Errorf("code = %v, want %v", st.Code(), tt.wantCode) + } + if st.Message() != "boom" { + t.Errorf("message = %q, want %q", st.Message(), "boom") + } + }) + } +} + +// TestErrorRoundTripRejectsProcessCrashed asserts this package cannot +// construct a process_crashed *tool.Error either — the kernel-only +// category pkg/tool itself makes unconstructable, unchanged by reuse here. +func TestErrorRoundTripRejectsProcessCrashed(t *testing.T) { + t.Parallel() + + _, err := tool.NewError(tool.ErrorCategory(8), "subprocess died", false, nil) + if !errors.Is(err, tool.ErrProcessCrashedCategory) { + t.Fatalf("tool.NewError(process_crashed numeric value, ...) error = %v, want wrapping %v", err, tool.ErrProcessCrashedCategory) + } +} diff --git a/pkg/slashcommand/helpers_test.go b/pkg/slashcommand/helpers_test.go new file mode 100644 index 0000000..69812cd --- /dev/null +++ b/pkg/slashcommand/helpers_test.go @@ -0,0 +1,134 @@ +package slashcommand_test + +import ( + "context" + "net" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" + schemav1 "github.com/pluggableharness/agent/pkg/schema/proto/v1" + "github.com/pluggableharness/agent/pkg/slashcommand" + slashcommandv1 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" + "github.com/pluggableharness/agent/pkg/tool" +) + +// fakeProvider is a hand-written slashcommand.Provider fake (go-testing.md: +// fakes, not mocking frameworks). Each method's behavior is controlled by a +// caller-set func field; a nil field falls through to a harmless default. +type fakeProvider struct { + capabilitiesFunc func(ctx context.Context) ([]*slashcommand.Spec, error) + configureFunc func(ctx context.Context, config map[string]any) error + invokeFunc func(ctx context.Context, call *slashcommand.Call, stream *slashcommand.Stream) error +} + +func (f *fakeProvider) Capabilities(ctx context.Context) ([]*slashcommand.Spec, error) { + if f.capabilitiesFunc != nil { + return f.capabilitiesFunc(ctx) + } + return nil, nil +} + +func (f *fakeProvider) Configure(ctx context.Context, config map[string]any) error { + if f.configureFunc != nil { + return f.configureFunc(ctx, config) + } + return nil +} + +func (f *fakeProvider) Invoke(ctx context.Context, call *slashcommand.Call, stream *slashcommand.Stream) error { + if f.invokeFunc != nil { + return f.invokeFunc(ctx, call, stream) + } + return stream.Send(slashcommand.NewResultEvent(map[string]any{})) +} + +var _ slashcommand.Provider = (*fakeProvider)(nil) + +// fakeFullProvider embeds fakeProvider and additionally implements every +// optional interface this package defines (Renderer, Previewer, +// ConfigSchemaProvider, HookPointProvider), each controlled by its own func +// field — used by tests exercising the optional-capability paths. Kept as a +// distinct type from fakeProvider so tests can also exercise the "provider +// does not implement this optional interface" fallback paths against a +// plain *fakeProvider. +type fakeFullProvider struct { + *fakeProvider + + renderFunc func(ctx context.Context, payload []byte, schemaVersion string) (*renderv1.RenderTree, error) + previewFunc func(ctx context.Context, call *slashcommand.Call) (*renderv1.RenderTree, error) + configSchemaFunc func() (*configv1.ConfigSchema, error) + hookPoints []commonv1.HookPoint +} + +func (f *fakeFullProvider) Render(ctx context.Context, payload []byte, schemaVersion string) (*renderv1.RenderTree, error) { + return f.renderFunc(ctx, payload, schemaVersion) +} + +func (f *fakeFullProvider) Preview(ctx context.Context, call *slashcommand.Call) (*renderv1.RenderTree, error) { + return f.previewFunc(ctx, call) +} + +func (f *fakeFullProvider) ConfigSchema() (*configv1.ConfigSchema, error) { + return f.configSchemaFunc() +} + +func (f *fakeFullProvider) SupportedHookPoints() []commonv1.HookPoint { + return f.hookPoints +} + +var ( + _ slashcommand.Provider = (*fakeFullProvider)(nil) + _ slashcommand.Renderer = (*fakeFullProvider)(nil) + _ slashcommand.Previewer = (*fakeFullProvider)(nil) + _ slashcommand.ConfigSchemaProvider = (*fakeFullProvider)(nil) + _ slashcommand.HookPointProvider = (*fakeFullProvider)(nil) +) + +// validSpec returns a minimally valid *slashcommand.Spec for tests that +// just need something toProtoSpec (via GetCapabilities) accepts. +func validSpec(name string) *slashcommand.Spec { + return &slashcommand.Spec{ + Name: name, + Description: "a test command", + InputSchema: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_OBJECT}, + Kind: tool.KindDataSource, + Risk: tool.RiskClassReadOnly, + Streaming: false, + Concurrency: &tool.ConcurrencySpec{Safe: true}, + } +} + +// newTestClient starts a *slashcommand.Service wrapping p on an in-memory +// bufconn listener and returns a real slashcommandv1.SlashCommandServiceClient +// dialed against it — a real gRPC round trip (go-testing.md), not a +// hand-rolled interface fake, mirroring pkg/tool/helpers_test.go's +// newTestClient. +func newTestClient(t *testing.T, p slashcommand.Provider) slashcommandv1.SlashCommandServiceClient { + t.Helper() + + svc := slashcommand.NewService(p, plugin.Identity{Name: "fake-slashcommand", Version: "0.0.1", Source: "local/fake"}, plugin.NewCallback()) + + const bufSize = 1 << 20 + lis := bufconn.Listen(bufSize) + + gs := grpc.NewServer() + svc.Register(gs) + go func() { _ = gs.Serve(lis) }() + t.Cleanup(gs.Stop) + + dialer := func(context.Context, string) (net.Conn, error) { return lis.Dial() } + conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(dialer), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("grpc.NewClient: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + return slashcommandv1.NewSlashCommandServiceClient(conn) +} diff --git a/pkg/slashcommand/proto/v1/events.pb.go b/pkg/slashcommand/proto/v1/events.pb.go new file mode 100644 index 0000000..7de4683 --- /dev/null +++ b/pkg/slashcommand/proto/v1/events.pb.go @@ -0,0 +1,562 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/slashcommand/v1/events.proto + +package slashcommandv1 + +import ( + v1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// InvokeResponse wraps one message in the stream Invoke returns. A thin +// per-RPC envelope around SlashCommandEvent, which keeps its own rich +// structure independent of the RPC signature. +type InvokeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The event. + Event *SlashCommandEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InvokeResponse) Reset() { + *x = InvokeResponse{} + mi := &file_pluggableharness_slashcommand_v1_events_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InvokeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InvokeResponse) ProtoMessage() {} + +func (x *InvokeResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_events_proto_msgTypes[0] + 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 InvokeResponse.ProtoReflect.Descriptor instead. +func (*InvokeResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_events_proto_rawDescGZIP(), []int{0} +} + +func (x *InvokeResponse) GetEvent() *SlashCommandEvent { + if x != nil { + return x.Event + } + return nil +} + +// SlashCommandEvent is one message in the stream Invoke returns, +// structurally identical to pluggableharness.tool.v1.ToolEvent — the +// same streaming contract applies verbatim (exactly one of +// `result`/`error` closes the stream; `output_chunk`, `progress`, and +// `partial_result` MAY each appear zero or more times before it; +// `exit_status` MAY appear at most once; the relative order of +// `output_chunk` events MUST be preserved by the transport). The +// terminal result/error and the output-stream discriminator are the +// same pluggableharness.tool.v1 types tool.v1.ToolEvent uses, reused +// here rather than redeclared — a direct-invoke command's result is the +// same kind of thing a tool call's result is. +type SlashCommandEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *SlashCommandEvent_OutputChunk_ + // *SlashCommandEvent_Progress_ + // *SlashCommandEvent_PartialResult_ + // *SlashCommandEvent_ExitStatus_ + // *SlashCommandEvent_Result + // *SlashCommandEvent_Error + Event isSlashCommandEvent_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SlashCommandEvent) Reset() { + *x = SlashCommandEvent{} + mi := &file_pluggableharness_slashcommand_v1_events_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SlashCommandEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SlashCommandEvent) ProtoMessage() {} + +func (x *SlashCommandEvent) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_events_proto_msgTypes[1] + 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 SlashCommandEvent.ProtoReflect.Descriptor instead. +func (*SlashCommandEvent) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_events_proto_rawDescGZIP(), []int{1} +} + +func (x *SlashCommandEvent) GetEvent() isSlashCommandEvent_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *SlashCommandEvent) GetOutputChunk() *SlashCommandEvent_OutputChunk { + if x != nil { + if x, ok := x.Event.(*SlashCommandEvent_OutputChunk_); ok { + return x.OutputChunk + } + } + return nil +} + +func (x *SlashCommandEvent) GetProgress() *SlashCommandEvent_Progress { + if x != nil { + if x, ok := x.Event.(*SlashCommandEvent_Progress_); ok { + return x.Progress + } + } + return nil +} + +func (x *SlashCommandEvent) GetPartialResult() *SlashCommandEvent_PartialResult { + if x != nil { + if x, ok := x.Event.(*SlashCommandEvent_PartialResult_); ok { + return x.PartialResult + } + } + return nil +} + +func (x *SlashCommandEvent) GetExitStatus() *SlashCommandEvent_ExitStatus { + if x != nil { + if x, ok := x.Event.(*SlashCommandEvent_ExitStatus_); ok { + return x.ExitStatus + } + } + return nil +} + +func (x *SlashCommandEvent) GetResult() *v1.ToolResult { + if x != nil { + if x, ok := x.Event.(*SlashCommandEvent_Result); ok { + return x.Result + } + } + return nil +} + +func (x *SlashCommandEvent) GetError() *v1.ToolError { + if x != nil { + if x, ok := x.Event.(*SlashCommandEvent_Error); ok { + return x.Error + } + } + return nil +} + +type isSlashCommandEvent_Event interface { + isSlashCommandEvent_Event() +} + +type SlashCommandEvent_OutputChunk_ struct { + // Incremental raw output from a process-backed command. + OutputChunk *SlashCommandEvent_OutputChunk `protobuf:"bytes,1,opt,name=output_chunk,json=outputChunk,proto3,oneof"` +} + +type SlashCommandEvent_Progress_ struct { + // A human-readable progress update. + Progress *SlashCommandEvent_Progress `protobuf:"bytes,2,opt,name=progress,proto3,oneof"` +} + +type SlashCommandEvent_PartialResult_ struct { + // Incremental structured output, e.g. search hits as they're found. + PartialResult *SlashCommandEvent_PartialResult `protobuf:"bytes,3,opt,name=partial_result,json=partialResult,proto3,oneof"` +} + +type SlashCommandEvent_ExitStatus_ struct { + // The exit status of a process-backed command's child process. + ExitStatus *SlashCommandEvent_ExitStatus `protobuf:"bytes,4,opt,name=exit_status,json=exitStatus,proto3,oneof"` +} + +type SlashCommandEvent_Result struct { + // The terminal, successful result of this call. + Result *v1.ToolResult `protobuf:"bytes,5,opt,name=result,proto3,oneof"` +} + +type SlashCommandEvent_Error struct { + // The terminal, failed result of this call. + Error *v1.ToolError `protobuf:"bytes,6,opt,name=error,proto3,oneof"` +} + +func (*SlashCommandEvent_OutputChunk_) isSlashCommandEvent_Event() {} + +func (*SlashCommandEvent_Progress_) isSlashCommandEvent_Event() {} + +func (*SlashCommandEvent_PartialResult_) isSlashCommandEvent_Event() {} + +func (*SlashCommandEvent_ExitStatus_) isSlashCommandEvent_Event() {} + +func (*SlashCommandEvent_Result) isSlashCommandEvent_Event() {} + +func (*SlashCommandEvent_Error) isSlashCommandEvent_Event() {} + +// OutputChunk carries one slice of raw stdout/stderr-shaped output +// from a process-backed command. +type SlashCommandEvent_OutputChunk struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Which stream this chunk came from. + Stream v1.OutputStream `protobuf:"varint,1,opt,name=stream,proto3,enum=pluggableharness.tool.v1.OutputStream" json:"stream,omitempty"` + // The chunk's raw bytes. + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SlashCommandEvent_OutputChunk) Reset() { + *x = SlashCommandEvent_OutputChunk{} + mi := &file_pluggableharness_slashcommand_v1_events_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SlashCommandEvent_OutputChunk) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SlashCommandEvent_OutputChunk) ProtoMessage() {} + +func (x *SlashCommandEvent_OutputChunk) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_events_proto_msgTypes[2] + 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 SlashCommandEvent_OutputChunk.ProtoReflect.Descriptor instead. +func (*SlashCommandEvent_OutputChunk) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_events_proto_rawDescGZIP(), []int{1, 0} +} + +func (x *SlashCommandEvent_OutputChunk) GetStream() v1.OutputStream { + if x != nil { + return x.Stream + } + return v1.OutputStream(0) +} + +func (x *SlashCommandEvent_OutputChunk) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +// Progress carries a human-readable status update for a long-running +// call. +type SlashCommandEvent_Progress struct { + state protoimpl.MessageState `protogen:"open.v1"` + // A human-readable description of the current step. + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + // How far through the operation this call is, in [0.0, 1.0]. Absent + // means the provider cannot estimate completion fraction. + FractionComplete *float64 `protobuf:"fixed64,2,opt,name=fraction_complete,json=fractionComplete,proto3,oneof" json:"fraction_complete,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SlashCommandEvent_Progress) Reset() { + *x = SlashCommandEvent_Progress{} + mi := &file_pluggableharness_slashcommand_v1_events_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SlashCommandEvent_Progress) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SlashCommandEvent_Progress) ProtoMessage() {} + +func (x *SlashCommandEvent_Progress) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_events_proto_msgTypes[3] + 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 SlashCommandEvent_Progress.ProtoReflect.Descriptor instead. +func (*SlashCommandEvent_Progress) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_events_proto_rawDescGZIP(), []int{1, 1} +} + +func (x *SlashCommandEvent_Progress) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *SlashCommandEvent_Progress) GetFractionComplete() float64 { + if x != nil && x.FractionComplete != nil { + return *x.FractionComplete + } + return 0 +} + +// PartialResult carries incremental structured output before the +// terminal result, e.g. search hits as they're found. +type SlashCommandEvent_PartialResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The incremental structured payload. + Payload *structpb.Struct `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SlashCommandEvent_PartialResult) Reset() { + *x = SlashCommandEvent_PartialResult{} + mi := &file_pluggableharness_slashcommand_v1_events_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SlashCommandEvent_PartialResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SlashCommandEvent_PartialResult) ProtoMessage() {} + +func (x *SlashCommandEvent_PartialResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_events_proto_msgTypes[4] + 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 SlashCommandEvent_PartialResult.ProtoReflect.Descriptor instead. +func (*SlashCommandEvent_PartialResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_events_proto_rawDescGZIP(), []int{1, 2} +} + +func (x *SlashCommandEvent_PartialResult) GetPayload() *structpb.Struct { + if x != nil { + return x.Payload + } + return nil +} + +// ExitStatus carries a process-backed command's child process exit +// information. Only meaningful for a command whose implementation +// shells out — a command with no child process MUST NOT emit this. +// Appears at most once per Invoke stream. +type SlashCommandEvent_ExitStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The child process's exit code. + ExitCode int32 `protobuf:"varint,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + // The signal that terminated the child process, if any. Absent + // means the process exited normally (exit_code is meaningful on its + // own). + Signal *string `protobuf:"bytes,2,opt,name=signal,proto3,oneof" json:"signal,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SlashCommandEvent_ExitStatus) Reset() { + *x = SlashCommandEvent_ExitStatus{} + mi := &file_pluggableharness_slashcommand_v1_events_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SlashCommandEvent_ExitStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SlashCommandEvent_ExitStatus) ProtoMessage() {} + +func (x *SlashCommandEvent_ExitStatus) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_events_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 SlashCommandEvent_ExitStatus.ProtoReflect.Descriptor instead. +func (*SlashCommandEvent_ExitStatus) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_events_proto_rawDescGZIP(), []int{1, 3} +} + +func (x *SlashCommandEvent_ExitStatus) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *SlashCommandEvent_ExitStatus) GetSignal() string { + if x != nil && x.Signal != nil { + return *x.Signal + } + return "" +} + +var File_pluggableharness_slashcommand_v1_events_proto protoreflect.FileDescriptor + +const file_pluggableharness_slashcommand_v1_events_proto_rawDesc = "" + + "\n" + + "-pluggableharness/slashcommand/v1/events.proto\x12 pluggableharness.slashcommand.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a%pluggableharness/tool/v1/errors.proto\x1a%pluggableharness/tool/v1/events.proto\x1a$pluggableharness/tool/v1/types.proto\"[\n" + + "\x0eInvokeResponse\x12I\n" + + "\x05event\x18\x01 \x01(\v23.pluggableharness.slashcommand.v1.SlashCommandEventR\x05event\"\x92\a\n" + + "\x11SlashCommandEvent\x12d\n" + + "\foutput_chunk\x18\x01 \x01(\v2?.pluggableharness.slashcommand.v1.SlashCommandEvent.OutputChunkH\x00R\voutputChunk\x12Z\n" + + "\bprogress\x18\x02 \x01(\v2<.pluggableharness.slashcommand.v1.SlashCommandEvent.ProgressH\x00R\bprogress\x12j\n" + + "\x0epartial_result\x18\x03 \x01(\v2A.pluggableharness.slashcommand.v1.SlashCommandEvent.PartialResultH\x00R\rpartialResult\x12a\n" + + "\vexit_status\x18\x04 \x01(\v2>.pluggableharness.slashcommand.v1.SlashCommandEvent.ExitStatusH\x00R\n" + + "exitStatus\x12>\n" + + "\x06result\x18\x05 \x01(\v2$.pluggableharness.tool.v1.ToolResultH\x00R\x06result\x12;\n" + + "\x05error\x18\x06 \x01(\v2#.pluggableharness.tool.v1.ToolErrorH\x00R\x05error\x1aa\n" + + "\vOutputChunk\x12>\n" + + "\x06stream\x18\x01 \x01(\x0e2&.pluggableharness.tool.v1.OutputStreamR\x06stream\x12\x12\n" + + "\x04data\x18\x02 \x01(\fR\x04data\x1al\n" + + "\bProgress\x12\x18\n" + + "\amessage\x18\x01 \x01(\tR\amessage\x120\n" + + "\x11fraction_complete\x18\x02 \x01(\x01H\x00R\x10fractionComplete\x88\x01\x01B\x14\n" + + "\x12_fraction_complete\x1aB\n" + + "\rPartialResult\x121\n" + + "\apayload\x18\x01 \x01(\v2\x17.google.protobuf.StructR\apayload\x1aQ\n" + + "\n" + + "ExitStatus\x12\x1b\n" + + "\texit_code\x18\x01 \x01(\x05R\bexitCode\x12\x1b\n" + + "\x06signal\x18\x02 \x01(\tH\x00R\x06signal\x88\x01\x01B\t\n" + + "\a_signalB\a\n" + + "\x05eventBLZJgithub.com/pluggableharness/agent/pkg/slashcommand/proto/v1;slashcommandv1b\x06proto3" + +var ( + file_pluggableharness_slashcommand_v1_events_proto_rawDescOnce sync.Once + file_pluggableharness_slashcommand_v1_events_proto_rawDescData []byte +) + +func file_pluggableharness_slashcommand_v1_events_proto_rawDescGZIP() []byte { + file_pluggableharness_slashcommand_v1_events_proto_rawDescOnce.Do(func() { + file_pluggableharness_slashcommand_v1_events_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_slashcommand_v1_events_proto_rawDesc), len(file_pluggableharness_slashcommand_v1_events_proto_rawDesc))) + }) + return file_pluggableharness_slashcommand_v1_events_proto_rawDescData +} + +var file_pluggableharness_slashcommand_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_pluggableharness_slashcommand_v1_events_proto_goTypes = []any{ + (*InvokeResponse)(nil), // 0: pluggableharness.slashcommand.v1.InvokeResponse + (*SlashCommandEvent)(nil), // 1: pluggableharness.slashcommand.v1.SlashCommandEvent + (*SlashCommandEvent_OutputChunk)(nil), // 2: pluggableharness.slashcommand.v1.SlashCommandEvent.OutputChunk + (*SlashCommandEvent_Progress)(nil), // 3: pluggableharness.slashcommand.v1.SlashCommandEvent.Progress + (*SlashCommandEvent_PartialResult)(nil), // 4: pluggableharness.slashcommand.v1.SlashCommandEvent.PartialResult + (*SlashCommandEvent_ExitStatus)(nil), // 5: pluggableharness.slashcommand.v1.SlashCommandEvent.ExitStatus + (*v1.ToolResult)(nil), // 6: pluggableharness.tool.v1.ToolResult + (*v1.ToolError)(nil), // 7: pluggableharness.tool.v1.ToolError + (v1.OutputStream)(0), // 8: pluggableharness.tool.v1.OutputStream + (*structpb.Struct)(nil), // 9: google.protobuf.Struct +} +var file_pluggableharness_slashcommand_v1_events_proto_depIdxs = []int32{ + 1, // 0: pluggableharness.slashcommand.v1.InvokeResponse.event:type_name -> pluggableharness.slashcommand.v1.SlashCommandEvent + 2, // 1: pluggableharness.slashcommand.v1.SlashCommandEvent.output_chunk:type_name -> pluggableharness.slashcommand.v1.SlashCommandEvent.OutputChunk + 3, // 2: pluggableharness.slashcommand.v1.SlashCommandEvent.progress:type_name -> pluggableharness.slashcommand.v1.SlashCommandEvent.Progress + 4, // 3: pluggableharness.slashcommand.v1.SlashCommandEvent.partial_result:type_name -> pluggableharness.slashcommand.v1.SlashCommandEvent.PartialResult + 5, // 4: pluggableharness.slashcommand.v1.SlashCommandEvent.exit_status:type_name -> pluggableharness.slashcommand.v1.SlashCommandEvent.ExitStatus + 6, // 5: pluggableharness.slashcommand.v1.SlashCommandEvent.result:type_name -> pluggableharness.tool.v1.ToolResult + 7, // 6: pluggableharness.slashcommand.v1.SlashCommandEvent.error:type_name -> pluggableharness.tool.v1.ToolError + 8, // 7: pluggableharness.slashcommand.v1.SlashCommandEvent.OutputChunk.stream:type_name -> pluggableharness.tool.v1.OutputStream + 9, // 8: pluggableharness.slashcommand.v1.SlashCommandEvent.PartialResult.payload:type_name -> google.protobuf.Struct + 9, // [9:9] is the sub-list for method output_type + 9, // [9:9] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name +} + +func init() { file_pluggableharness_slashcommand_v1_events_proto_init() } +func file_pluggableharness_slashcommand_v1_events_proto_init() { + if File_pluggableharness_slashcommand_v1_events_proto != nil { + return + } + file_pluggableharness_slashcommand_v1_events_proto_msgTypes[1].OneofWrappers = []any{ + (*SlashCommandEvent_OutputChunk_)(nil), + (*SlashCommandEvent_Progress_)(nil), + (*SlashCommandEvent_PartialResult_)(nil), + (*SlashCommandEvent_ExitStatus_)(nil), + (*SlashCommandEvent_Result)(nil), + (*SlashCommandEvent_Error)(nil), + } + file_pluggableharness_slashcommand_v1_events_proto_msgTypes[3].OneofWrappers = []any{} + file_pluggableharness_slashcommand_v1_events_proto_msgTypes[5].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_slashcommand_v1_events_proto_rawDesc), len(file_pluggableharness_slashcommand_v1_events_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_slashcommand_v1_events_proto_goTypes, + DependencyIndexes: file_pluggableharness_slashcommand_v1_events_proto_depIdxs, + MessageInfos: file_pluggableharness_slashcommand_v1_events_proto_msgTypes, + }.Build() + File_pluggableharness_slashcommand_v1_events_proto = out.File + file_pluggableharness_slashcommand_v1_events_proto_goTypes = nil + file_pluggableharness_slashcommand_v1_events_proto_depIdxs = nil +} diff --git a/pkg/slashcommand/proto/v1/rpc_request.pb.go b/pkg/slashcommand/proto/v1/rpc_request.pb.go new file mode 100644 index 0000000..4c1ea46 --- /dev/null +++ b/pkg/slashcommand/proto/v1/rpc_request.pb.go @@ -0,0 +1,378 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/slashcommand/v1/rpc_request.proto + +package slashcommandv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// GetCapabilitiesRequest carries no fields — GetCapabilities takes no +// parameters. +type GetCapabilitiesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCapabilitiesRequest) Reset() { + *x = GetCapabilitiesRequest{} + mi := &file_pluggableharness_slashcommand_v1_rpc_request_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCapabilitiesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCapabilitiesRequest) ProtoMessage() {} + +func (x *GetCapabilitiesRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_rpc_request_proto_msgTypes[0] + 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 GetCapabilitiesRequest.ProtoReflect.Descriptor instead. +func (*GetCapabilitiesRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_rpc_request_proto_rawDescGZIP(), []int{0} +} + +// ConfigureRequest wraps this provider's already-decoded agent.hcl +// config. +type ConfigureRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The provider-specific config, decoded from agent.hcl via the + // schema-to-cty bridge before crossing the wire. + Config *structpb.Struct `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureRequest) Reset() { + *x = ConfigureRequest{} + mi := &file_pluggableharness_slashcommand_v1_rpc_request_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureRequest) ProtoMessage() {} + +func (x *ConfigureRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_rpc_request_proto_msgTypes[1] + 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 ConfigureRequest.ProtoReflect.Descriptor instead. +func (*ConfigureRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_rpc_request_proto_rawDescGZIP(), []int{1} +} + +func (x *ConfigureRequest) GetConfig() *structpb.Struct { + if x != nil { + return x.Config + } + return nil +} + +// InvokeRequest wraps the call to execute. A thin per-RPC envelope +// around SlashCommandCall, which keeps its own rich structure +// independent of the RPC signature. +type InvokeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The call to execute. + Call *SlashCommandCall `protobuf:"bytes,1,opt,name=call,proto3" json:"call,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InvokeRequest) Reset() { + *x = InvokeRequest{} + mi := &file_pluggableharness_slashcommand_v1_rpc_request_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InvokeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InvokeRequest) ProtoMessage() {} + +func (x *InvokeRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_rpc_request_proto_msgTypes[2] + 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 InvokeRequest.ProtoReflect.Descriptor instead. +func (*InvokeRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_rpc_request_proto_rawDescGZIP(), []int{2} +} + +func (x *InvokeRequest) GetCall() *SlashCommandCall { + if x != nil { + return x.Call + } + return nil +} + +// RenderRequest carries the opaque payload to render. See grpc.md's +// Emit->Render->Paint carve-out for why this field stays `bytes` rather +// than a strongly-typed message. +type RenderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The opaque emitted payload to render. + Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` + // The schema version the payload was emitted under, per + // ../frontend/render-tree.md#schema-versioning. Lets a Render + // implementation decode a payload emitted by an older build of this + // same plugin. + SchemaVersion string `protobuf:"bytes,2,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenderRequest) Reset() { + *x = RenderRequest{} + mi := &file_pluggableharness_slashcommand_v1_rpc_request_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenderRequest) ProtoMessage() {} + +func (x *RenderRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_rpc_request_proto_msgTypes[3] + 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 RenderRequest.ProtoReflect.Descriptor instead. +func (*RenderRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_rpc_request_proto_rawDescGZIP(), []int{3} +} + +func (x *RenderRequest) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +func (x *RenderRequest) GetSchemaVersion() string { + if x != nil { + return x.SchemaVersion + } + return "" +} + +// PreviewRequest wraps the call to describe, per +// slashcommand/protocol.md#preview. +type PreviewRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The call Preview describes a dry run of. Same shape as an Invoke + // request; Preview MUST NOT execute it. + Call *SlashCommandCall `protobuf:"bytes,1,opt,name=call,proto3" json:"call,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PreviewRequest) Reset() { + *x = PreviewRequest{} + mi := &file_pluggableharness_slashcommand_v1_rpc_request_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PreviewRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PreviewRequest) ProtoMessage() {} + +func (x *PreviewRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_rpc_request_proto_msgTypes[4] + 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 PreviewRequest.ProtoReflect.Descriptor instead. +func (*PreviewRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_rpc_request_proto_rawDescGZIP(), []int{4} +} + +func (x *PreviewRequest) GetCall() *SlashCommandCall { + if x != nil { + return x.Call + } + return nil +} + +// DescribeRequest carries no fields — Describe takes no parameters. +type DescribeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeRequest) Reset() { + *x = DescribeRequest{} + mi := &file_pluggableharness_slashcommand_v1_rpc_request_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeRequest) ProtoMessage() {} + +func (x *DescribeRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_rpc_request_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 DescribeRequest.ProtoReflect.Descriptor instead. +func (*DescribeRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_rpc_request_proto_rawDescGZIP(), []int{5} +} + +var File_pluggableharness_slashcommand_v1_rpc_request_proto protoreflect.FileDescriptor + +const file_pluggableharness_slashcommand_v1_rpc_request_proto_rawDesc = "" + + "\n" + + "2pluggableharness/slashcommand/v1/rpc_request.proto\x12 pluggableharness.slashcommand.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a,pluggableharness/slashcommand/v1/types.proto\"\x18\n" + + "\x16GetCapabilitiesRequest\"C\n" + + "\x10ConfigureRequest\x12/\n" + + "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06config\"W\n" + + "\rInvokeRequest\x12F\n" + + "\x04call\x18\x01 \x01(\v22.pluggableharness.slashcommand.v1.SlashCommandCallR\x04call\"P\n" + + "\rRenderRequest\x12\x18\n" + + "\apayload\x18\x01 \x01(\fR\apayload\x12%\n" + + "\x0eschema_version\x18\x02 \x01(\tR\rschemaVersion\"X\n" + + "\x0ePreviewRequest\x12F\n" + + "\x04call\x18\x01 \x01(\v22.pluggableharness.slashcommand.v1.SlashCommandCallR\x04call\"\x11\n" + + "\x0fDescribeRequestBLZJgithub.com/pluggableharness/agent/pkg/slashcommand/proto/v1;slashcommandv1b\x06proto3" + +var ( + file_pluggableharness_slashcommand_v1_rpc_request_proto_rawDescOnce sync.Once + file_pluggableharness_slashcommand_v1_rpc_request_proto_rawDescData []byte +) + +func file_pluggableharness_slashcommand_v1_rpc_request_proto_rawDescGZIP() []byte { + file_pluggableharness_slashcommand_v1_rpc_request_proto_rawDescOnce.Do(func() { + file_pluggableharness_slashcommand_v1_rpc_request_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_slashcommand_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_slashcommand_v1_rpc_request_proto_rawDesc))) + }) + return file_pluggableharness_slashcommand_v1_rpc_request_proto_rawDescData +} + +var file_pluggableharness_slashcommand_v1_rpc_request_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_pluggableharness_slashcommand_v1_rpc_request_proto_goTypes = []any{ + (*GetCapabilitiesRequest)(nil), // 0: pluggableharness.slashcommand.v1.GetCapabilitiesRequest + (*ConfigureRequest)(nil), // 1: pluggableharness.slashcommand.v1.ConfigureRequest + (*InvokeRequest)(nil), // 2: pluggableharness.slashcommand.v1.InvokeRequest + (*RenderRequest)(nil), // 3: pluggableharness.slashcommand.v1.RenderRequest + (*PreviewRequest)(nil), // 4: pluggableharness.slashcommand.v1.PreviewRequest + (*DescribeRequest)(nil), // 5: pluggableharness.slashcommand.v1.DescribeRequest + (*structpb.Struct)(nil), // 6: google.protobuf.Struct + (*SlashCommandCall)(nil), // 7: pluggableharness.slashcommand.v1.SlashCommandCall +} +var file_pluggableharness_slashcommand_v1_rpc_request_proto_depIdxs = []int32{ + 6, // 0: pluggableharness.slashcommand.v1.ConfigureRequest.config:type_name -> google.protobuf.Struct + 7, // 1: pluggableharness.slashcommand.v1.InvokeRequest.call:type_name -> pluggableharness.slashcommand.v1.SlashCommandCall + 7, // 2: pluggableharness.slashcommand.v1.PreviewRequest.call:type_name -> pluggableharness.slashcommand.v1.SlashCommandCall + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_pluggableharness_slashcommand_v1_rpc_request_proto_init() } +func file_pluggableharness_slashcommand_v1_rpc_request_proto_init() { + if File_pluggableharness_slashcommand_v1_rpc_request_proto != nil { + return + } + file_pluggableharness_slashcommand_v1_types_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_slashcommand_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_slashcommand_v1_rpc_request_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_slashcommand_v1_rpc_request_proto_goTypes, + DependencyIndexes: file_pluggableharness_slashcommand_v1_rpc_request_proto_depIdxs, + MessageInfos: file_pluggableharness_slashcommand_v1_rpc_request_proto_msgTypes, + }.Build() + File_pluggableharness_slashcommand_v1_rpc_request_proto = out.File + file_pluggableharness_slashcommand_v1_rpc_request_proto_goTypes = nil + file_pluggableharness_slashcommand_v1_rpc_request_proto_depIdxs = nil +} diff --git a/pkg/slashcommand/proto/v1/rpc_response.pb.go b/pkg/slashcommand/proto/v1/rpc_response.pb.go new file mode 100644 index 0000000..ed48ceb --- /dev/null +++ b/pkg/slashcommand/proto/v1/rpc_response.pb.go @@ -0,0 +1,369 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/slashcommand/v1/rpc_response.proto + +package slashcommandv1 + +import ( + v11 "github.com/pluggableharness/agent/pkg/common/proto/v1" + v1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + v12 "github.com/pluggableharness/agent/pkg/render/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// GetCapabilitiesResponse is this provider's complete capability +// advertisement. +type GetCapabilitiesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // One entry per direct-invoke command this plugin exposes. + Commands []*SlashCommandSpec `protobuf:"bytes,1,rep,name=commands,proto3" json:"commands,omitempty"` + // This provider's agent.hcl config schema, per configuration.md §4 — + // what fields Configure's request may be decoded from. + ConfigSchema *v1.ConfigSchema `protobuf:"bytes,2,opt,name=config_schema,json=configSchema,proto3" json:"config_schema,omitempty"` + // Which of the eight dispatchable hook points (common.v1.HookPoint) + // this provider subscribes a HookSubscriberService to, per this + // provider's own agent.hcl hook{} blocks. Lets the kernel validate a + // hook{} declaration at config-load time instead of discovering an + // unsupported subscription only when that hook point first fires. + SupportedHookPoints []v11.HookPoint `protobuf:"varint,3,rep,packed,name=supported_hook_points,json=supportedHookPoints,proto3,enum=pluggableharness.common.v1.HookPoint" json:"supported_hook_points,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCapabilitiesResponse) Reset() { + *x = GetCapabilitiesResponse{} + mi := &file_pluggableharness_slashcommand_v1_rpc_response_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCapabilitiesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCapabilitiesResponse) ProtoMessage() {} + +func (x *GetCapabilitiesResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_rpc_response_proto_msgTypes[0] + 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 GetCapabilitiesResponse.ProtoReflect.Descriptor instead. +func (*GetCapabilitiesResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_rpc_response_proto_rawDescGZIP(), []int{0} +} + +func (x *GetCapabilitiesResponse) GetCommands() []*SlashCommandSpec { + if x != nil { + return x.Commands + } + return nil +} + +func (x *GetCapabilitiesResponse) GetConfigSchema() *v1.ConfigSchema { + if x != nil { + return x.ConfigSchema + } + return nil +} + +func (x *GetCapabilitiesResponse) GetSupportedHookPoints() []v11.HookPoint { + if x != nil { + return x.SupportedHookPoints + } + return nil +} + +// ConfigureResponse is empty on success. Errors surface as a gRPC status +// carrying a pluggableharness.tool.v1.ToolError in its detail, per +// grpc.md — not an in-band field here. +type ConfigureResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureResponse) Reset() { + *x = ConfigureResponse{} + mi := &file_pluggableharness_slashcommand_v1_rpc_response_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureResponse) ProtoMessage() {} + +func (x *ConfigureResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_rpc_response_proto_msgTypes[1] + 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 ConfigureResponse.ProtoReflect.Descriptor instead. +func (*ConfigureResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_rpc_response_proto_rawDescGZIP(), []int{1} +} + +// RenderResponse wraps the rendered tree. A thin per-RPC envelope around +// RenderTree, which keeps its own rich structure independent of the RPC +// signature. +type RenderResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The rendered tree. + Tree *v12.RenderTree `protobuf:"bytes,1,opt,name=tree,proto3" json:"tree,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenderResponse) Reset() { + *x = RenderResponse{} + mi := &file_pluggableharness_slashcommand_v1_rpc_response_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenderResponse) ProtoMessage() {} + +func (x *RenderResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_rpc_response_proto_msgTypes[2] + 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 RenderResponse.ProtoReflect.Descriptor instead. +func (*RenderResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_rpc_response_proto_rawDescGZIP(), []int{2} +} + +func (x *RenderResponse) GetTree() *v12.RenderTree { + if x != nil { + return x.Tree + } + return nil +} + +// PreviewResponse carries a dry-run, human-readable description of what +// Invoke(call) would do, per slashcommand/protocol.md#preview. Rendered +// into the plan/apply gate's permission UI via PlanItem.preview +// (pluggableharness.plan.v1) — that field and this response share the +// same pluggableharness.render.v1.RenderTree type by design, exactly as +// tool.v1.PreviewResponse's does for a tool call. +type PreviewResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The dry-run preview, rendered as a RenderTree. Producing this MUST + // NOT mutate anything and MUST be side-effect-free — the same + // guarantee a TOOL_KIND_DATA_SOURCE operation makes, but + // unconditionally, regardless of the call's actual + // pluggableharness.tool.v1.ToolKind. + Preview *v12.RenderTree `protobuf:"bytes,1,opt,name=preview,proto3" json:"preview,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PreviewResponse) Reset() { + *x = PreviewResponse{} + mi := &file_pluggableharness_slashcommand_v1_rpc_response_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PreviewResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PreviewResponse) ProtoMessage() {} + +func (x *PreviewResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_rpc_response_proto_msgTypes[3] + 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 PreviewResponse.ProtoReflect.Descriptor instead. +func (*PreviewResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_rpc_response_proto_rawDescGZIP(), []int{3} +} + +func (x *PreviewResponse) GetPreview() *v12.RenderTree { + if x != nil { + return x.Preview + } + return nil +} + +// DescribeResponse reports this plugin build's own identity, per +// configuration/lock-file.md's dev_overrides note: a +// dev_overrides-resolved plugin has no lock-file entry for the kernel to +// read {name, version, source, category, protocol_version} from, so the +// kernel obtains it directly from the running process via this RPC +// instead. +type DescribeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // This plugin build's identity. + Producer *v11.ProducerRef `protobuf:"bytes,1,opt,name=producer,proto3" json:"producer,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeResponse) Reset() { + *x = DescribeResponse{} + mi := &file_pluggableharness_slashcommand_v1_rpc_response_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeResponse) ProtoMessage() {} + +func (x *DescribeResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_rpc_response_proto_msgTypes[4] + 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 DescribeResponse.ProtoReflect.Descriptor instead. +func (*DescribeResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_rpc_response_proto_rawDescGZIP(), []int{4} +} + +func (x *DescribeResponse) GetProducer() *v11.ProducerRef { + if x != nil { + return x.Producer + } + return nil +} + +var File_pluggableharness_slashcommand_v1_rpc_response_proto protoreflect.FileDescriptor + +const file_pluggableharness_slashcommand_v1_rpc_response_proto_rawDesc = "" + + "\n" + + "3pluggableharness/slashcommand/v1/rpc_response.proto\x12 pluggableharness.slashcommand.v1\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/config/v1/types.proto\x1a&pluggableharness/render/v1/types.proto\x1a,pluggableharness/slashcommand/v1/types.proto\"\x93\x02\n" + + "\x17GetCapabilitiesResponse\x12N\n" + + "\bcommands\x18\x01 \x03(\v22.pluggableharness.slashcommand.v1.SlashCommandSpecR\bcommands\x12M\n" + + "\rconfig_schema\x18\x02 \x01(\v2(.pluggableharness.config.v1.ConfigSchemaR\fconfigSchema\x12Y\n" + + "\x15supported_hook_points\x18\x03 \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPoints\"\x13\n" + + "\x11ConfigureResponse\"L\n" + + "\x0eRenderResponse\x12:\n" + + "\x04tree\x18\x01 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\x04tree\"S\n" + + "\x0fPreviewResponse\x12@\n" + + "\apreview\x18\x01 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\apreview\"W\n" + + "\x10DescribeResponse\x12C\n" + + "\bproducer\x18\x01 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\bproducerBLZJgithub.com/pluggableharness/agent/pkg/slashcommand/proto/v1;slashcommandv1b\x06proto3" + +var ( + file_pluggableharness_slashcommand_v1_rpc_response_proto_rawDescOnce sync.Once + file_pluggableharness_slashcommand_v1_rpc_response_proto_rawDescData []byte +) + +func file_pluggableharness_slashcommand_v1_rpc_response_proto_rawDescGZIP() []byte { + file_pluggableharness_slashcommand_v1_rpc_response_proto_rawDescOnce.Do(func() { + file_pluggableharness_slashcommand_v1_rpc_response_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_slashcommand_v1_rpc_response_proto_rawDesc), len(file_pluggableharness_slashcommand_v1_rpc_response_proto_rawDesc))) + }) + return file_pluggableharness_slashcommand_v1_rpc_response_proto_rawDescData +} + +var file_pluggableharness_slashcommand_v1_rpc_response_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_pluggableharness_slashcommand_v1_rpc_response_proto_goTypes = []any{ + (*GetCapabilitiesResponse)(nil), // 0: pluggableharness.slashcommand.v1.GetCapabilitiesResponse + (*ConfigureResponse)(nil), // 1: pluggableharness.slashcommand.v1.ConfigureResponse + (*RenderResponse)(nil), // 2: pluggableharness.slashcommand.v1.RenderResponse + (*PreviewResponse)(nil), // 3: pluggableharness.slashcommand.v1.PreviewResponse + (*DescribeResponse)(nil), // 4: pluggableharness.slashcommand.v1.DescribeResponse + (*SlashCommandSpec)(nil), // 5: pluggableharness.slashcommand.v1.SlashCommandSpec + (*v1.ConfigSchema)(nil), // 6: pluggableharness.config.v1.ConfigSchema + (v11.HookPoint)(0), // 7: pluggableharness.common.v1.HookPoint + (*v12.RenderTree)(nil), // 8: pluggableharness.render.v1.RenderTree + (*v11.ProducerRef)(nil), // 9: pluggableharness.common.v1.ProducerRef +} +var file_pluggableharness_slashcommand_v1_rpc_response_proto_depIdxs = []int32{ + 5, // 0: pluggableharness.slashcommand.v1.GetCapabilitiesResponse.commands:type_name -> pluggableharness.slashcommand.v1.SlashCommandSpec + 6, // 1: pluggableharness.slashcommand.v1.GetCapabilitiesResponse.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema + 7, // 2: pluggableharness.slashcommand.v1.GetCapabilitiesResponse.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint + 8, // 3: pluggableharness.slashcommand.v1.RenderResponse.tree:type_name -> pluggableharness.render.v1.RenderTree + 8, // 4: pluggableharness.slashcommand.v1.PreviewResponse.preview:type_name -> pluggableharness.render.v1.RenderTree + 9, // 5: pluggableharness.slashcommand.v1.DescribeResponse.producer:type_name -> pluggableharness.common.v1.ProducerRef + 6, // [6:6] is the sub-list for method output_type + 6, // [6:6] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_pluggableharness_slashcommand_v1_rpc_response_proto_init() } +func file_pluggableharness_slashcommand_v1_rpc_response_proto_init() { + if File_pluggableharness_slashcommand_v1_rpc_response_proto != nil { + return + } + file_pluggableharness_slashcommand_v1_types_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_slashcommand_v1_rpc_response_proto_rawDesc), len(file_pluggableharness_slashcommand_v1_rpc_response_proto_rawDesc)), + NumEnums: 0, + NumMessages: 5, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_slashcommand_v1_rpc_response_proto_goTypes, + DependencyIndexes: file_pluggableharness_slashcommand_v1_rpc_response_proto_depIdxs, + MessageInfos: file_pluggableharness_slashcommand_v1_rpc_response_proto_msgTypes, + }.Build() + File_pluggableharness_slashcommand_v1_rpc_response_proto = out.File + file_pluggableharness_slashcommand_v1_rpc_response_proto_goTypes = nil + file_pluggableharness_slashcommand_v1_rpc_response_proto_depIdxs = nil +} diff --git a/pkg/slashcommand/proto/v1/service.pb.go b/pkg/slashcommand/proto/v1/service.pb.go new file mode 100644 index 0000000..ab69521 --- /dev/null +++ b/pkg/slashcommand/proto/v1/service.pb.go @@ -0,0 +1,108 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/slashcommand/v1/service.proto + +// Package pluggableharness.slashcommand.v1 defines the slash-command +// provider plugin protocol described in specifications/slashcommand/. A +// direct-invoke slash command is a tool-shaped operation in its own +// right — this category exists so a plugin can declare and execute one +// without also being a tool provider and aliasing into one of its own +// tool operations. kind/risk/concurrency/ToolResult/ToolError/ +// OutputStream are pluggableharness.tool.v1 types reused verbatim here +// (identical gating and streaming semantics — see +// slashcommand/data-types.md), not redeclared. A prompt-expansion slash +// command (never executes anything, just expands a template) is not +// this category's concern — it stays declarable directly on any other +// category's own capability response as a PromptExpansionSpec (this +// package's types.proto). + +package slashcommandv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_pluggableharness_slashcommand_v1_service_proto protoreflect.FileDescriptor + +const file_pluggableharness_slashcommand_v1_service_proto_rawDesc = "" + + "\n" + + ".pluggableharness/slashcommand/v1/service.proto\x12 pluggableharness.slashcommand.v1\x1a-pluggableharness/slashcommand/v1/events.proto\x1a2pluggableharness/slashcommand/v1/rpc_request.proto\x1a3pluggableharness/slashcommand/v1/rpc_response.proto2\xd3\x05\n" + + "\x13SlashCommandService\x12\x86\x01\n" + + "\x0fGetCapabilities\x128.pluggableharness.slashcommand.v1.GetCapabilitiesRequest\x1a9.pluggableharness.slashcommand.v1.GetCapabilitiesResponse\x12t\n" + + "\tConfigure\x122.pluggableharness.slashcommand.v1.ConfigureRequest\x1a3.pluggableharness.slashcommand.v1.ConfigureResponse\x12m\n" + + "\x06Invoke\x12/.pluggableharness.slashcommand.v1.InvokeRequest\x1a0.pluggableharness.slashcommand.v1.InvokeResponse0\x01\x12k\n" + + "\x06Render\x12/.pluggableharness.slashcommand.v1.RenderRequest\x1a0.pluggableharness.slashcommand.v1.RenderResponse\x12n\n" + + "\aPreview\x120.pluggableharness.slashcommand.v1.PreviewRequest\x1a1.pluggableharness.slashcommand.v1.PreviewResponse\x12q\n" + + "\bDescribe\x121.pluggableharness.slashcommand.v1.DescribeRequest\x1a2.pluggableharness.slashcommand.v1.DescribeResponseBLZJgithub.com/pluggableharness/agent/pkg/slashcommand/proto/v1;slashcommandv1b\x06proto3" + +var file_pluggableharness_slashcommand_v1_service_proto_goTypes = []any{ + (*GetCapabilitiesRequest)(nil), // 0: pluggableharness.slashcommand.v1.GetCapabilitiesRequest + (*ConfigureRequest)(nil), // 1: pluggableharness.slashcommand.v1.ConfigureRequest + (*InvokeRequest)(nil), // 2: pluggableharness.slashcommand.v1.InvokeRequest + (*RenderRequest)(nil), // 3: pluggableharness.slashcommand.v1.RenderRequest + (*PreviewRequest)(nil), // 4: pluggableharness.slashcommand.v1.PreviewRequest + (*DescribeRequest)(nil), // 5: pluggableharness.slashcommand.v1.DescribeRequest + (*GetCapabilitiesResponse)(nil), // 6: pluggableharness.slashcommand.v1.GetCapabilitiesResponse + (*ConfigureResponse)(nil), // 7: pluggableharness.slashcommand.v1.ConfigureResponse + (*InvokeResponse)(nil), // 8: pluggableharness.slashcommand.v1.InvokeResponse + (*RenderResponse)(nil), // 9: pluggableharness.slashcommand.v1.RenderResponse + (*PreviewResponse)(nil), // 10: pluggableharness.slashcommand.v1.PreviewResponse + (*DescribeResponse)(nil), // 11: pluggableharness.slashcommand.v1.DescribeResponse +} +var file_pluggableharness_slashcommand_v1_service_proto_depIdxs = []int32{ + 0, // 0: pluggableharness.slashcommand.v1.SlashCommandService.GetCapabilities:input_type -> pluggableharness.slashcommand.v1.GetCapabilitiesRequest + 1, // 1: pluggableharness.slashcommand.v1.SlashCommandService.Configure:input_type -> pluggableharness.slashcommand.v1.ConfigureRequest + 2, // 2: pluggableharness.slashcommand.v1.SlashCommandService.Invoke:input_type -> pluggableharness.slashcommand.v1.InvokeRequest + 3, // 3: pluggableharness.slashcommand.v1.SlashCommandService.Render:input_type -> pluggableharness.slashcommand.v1.RenderRequest + 4, // 4: pluggableharness.slashcommand.v1.SlashCommandService.Preview:input_type -> pluggableharness.slashcommand.v1.PreviewRequest + 5, // 5: pluggableharness.slashcommand.v1.SlashCommandService.Describe:input_type -> pluggableharness.slashcommand.v1.DescribeRequest + 6, // 6: pluggableharness.slashcommand.v1.SlashCommandService.GetCapabilities:output_type -> pluggableharness.slashcommand.v1.GetCapabilitiesResponse + 7, // 7: pluggableharness.slashcommand.v1.SlashCommandService.Configure:output_type -> pluggableharness.slashcommand.v1.ConfigureResponse + 8, // 8: pluggableharness.slashcommand.v1.SlashCommandService.Invoke:output_type -> pluggableharness.slashcommand.v1.InvokeResponse + 9, // 9: pluggableharness.slashcommand.v1.SlashCommandService.Render:output_type -> pluggableharness.slashcommand.v1.RenderResponse + 10, // 10: pluggableharness.slashcommand.v1.SlashCommandService.Preview:output_type -> pluggableharness.slashcommand.v1.PreviewResponse + 11, // 11: pluggableharness.slashcommand.v1.SlashCommandService.Describe:output_type -> pluggableharness.slashcommand.v1.DescribeResponse + 6, // [6:12] is the sub-list for method output_type + 0, // [0:6] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_pluggableharness_slashcommand_v1_service_proto_init() } +func file_pluggableharness_slashcommand_v1_service_proto_init() { + if File_pluggableharness_slashcommand_v1_service_proto != nil { + return + } + file_pluggableharness_slashcommand_v1_events_proto_init() + file_pluggableharness_slashcommand_v1_rpc_request_proto_init() + file_pluggableharness_slashcommand_v1_rpc_response_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_slashcommand_v1_service_proto_rawDesc), len(file_pluggableharness_slashcommand_v1_service_proto_rawDesc)), + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_pluggableharness_slashcommand_v1_service_proto_goTypes, + DependencyIndexes: file_pluggableharness_slashcommand_v1_service_proto_depIdxs, + }.Build() + File_pluggableharness_slashcommand_v1_service_proto = out.File + file_pluggableharness_slashcommand_v1_service_proto_goTypes = nil + file_pluggableharness_slashcommand_v1_service_proto_depIdxs = nil +} diff --git a/pkg/slashcommand/proto/v1/service_grpc.pb.go b/pkg/slashcommand/proto/v1/service_grpc.pb.go new file mode 100644 index 0000000..4c4b692 --- /dev/null +++ b/pkg/slashcommand/proto/v1/service_grpc.pb.go @@ -0,0 +1,395 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc (unknown) +// source: pluggableharness/slashcommand/v1/service.proto + +// Package pluggableharness.slashcommand.v1 defines the slash-command +// provider plugin protocol described in specifications/slashcommand/. A +// direct-invoke slash command is a tool-shaped operation in its own +// right — this category exists so a plugin can declare and execute one +// without also being a tool provider and aliasing into one of its own +// tool operations. kind/risk/concurrency/ToolResult/ToolError/ +// OutputStream are pluggableharness.tool.v1 types reused verbatim here +// (identical gating and streaming semantics — see +// slashcommand/data-types.md), not redeclared. A prompt-expansion slash +// command (never executes anything, just expands a template) is not +// this category's concern — it stays declarable directly on any other +// category's own capability response as a PromptExpansionSpec (this +// package's types.proto). + +package slashcommandv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + SlashCommandService_GetCapabilities_FullMethodName = "/pluggableharness.slashcommand.v1.SlashCommandService/GetCapabilities" + SlashCommandService_Configure_FullMethodName = "/pluggableharness.slashcommand.v1.SlashCommandService/Configure" + SlashCommandService_Invoke_FullMethodName = "/pluggableharness.slashcommand.v1.SlashCommandService/Invoke" + SlashCommandService_Render_FullMethodName = "/pluggableharness.slashcommand.v1.SlashCommandService/Render" + SlashCommandService_Preview_FullMethodName = "/pluggableharness.slashcommand.v1.SlashCommandService/Preview" + SlashCommandService_Describe_FullMethodName = "/pluggableharness.slashcommand.v1.SlashCommandService/Describe" +) + +// SlashCommandServiceClient is the client API for SlashCommandService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// SlashCommandService is the slash-command provider plugin protocol +// described in specifications/slashcommand/protocol.md. A slashcommand +// provider plugin exposes GetCapabilities, Configure, and Invoke; it MAY +// additionally implement Render and Preview. +type SlashCommandServiceClient interface { + // GetCapabilities returns the SlashCommandSpec for every command this + // plugin exposes, per slashcommand/protocol.md#getcapabilities. MUST be + // cheaply re-queryable and MUST NOT require a network call. + GetCapabilities(ctx context.Context, in *GetCapabilitiesRequest, opts ...grpc.CallOption) (*GetCapabilitiesResponse, error) + // Configure decodes this provider's agent.hcl block, per + // slashcommand/protocol.md#configure. Errors surface as a gRPC status + // carrying a pluggableharness.tool.v1.ToolError in its structured + // detail, per grpc.md — not an in-band field on ConfigureResponse. + Configure(ctx context.Context, in *ConfigureRequest, opts ...grpc.CallOption) (*ConfigureResponse, error) + // Invoke executes one direct-invoke command and streams back its + // events, per slashcommand/protocol.md#invoke. Server-streaming, + // reusing tool/protocol.md#invoke's shape verbatim — a + // non-incremental command MUST still implement this shape, emitting + // exactly one terminal `result` or `error`. Cancellation is the kernel + // closing the gRPC stream; the plugin MUST treat this as normal + // control flow, never as an error. + Invoke(ctx context.Context, in *InvokeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[InvokeResponse], error) + // Render returns a RenderTree for a previously-emitted opaque payload, + // per slashcommand/protocol.md#render. MAY be implemented; if absent, + // the kernel falls back to its generic default (pretty-printed JSON + // payload). + Render(ctx context.Context, in *RenderRequest, opts ...grpc.CallOption) (*RenderResponse, error) + // Preview returns a dry-run, human-readable description of what + // Invoke would do for the given call, without performing it — per + // slashcommand/protocol.md#preview. MAY be implemented; a kernel MUST + // tolerate its absence and fall back to showing the call's raw + // arguments in the plan/apply gate's permission UI. + Preview(ctx context.Context, in *PreviewRequest, opts ...grpc.CallOption) (*PreviewResponse, error) + // Describe reports this plugin build's own identity — per + // slashcommand/protocol.md#describe and configuration/lock-file.md's + // dev_overrides note, this is how the kernel learns a + // dev_overrides-resolved plugin's {name, version, source, category, + // protocol_version} when there is no lock-file entry to read it from. + Describe(ctx context.Context, in *DescribeRequest, opts ...grpc.CallOption) (*DescribeResponse, error) +} + +type slashCommandServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewSlashCommandServiceClient(cc grpc.ClientConnInterface) SlashCommandServiceClient { + return &slashCommandServiceClient{cc} +} + +func (c *slashCommandServiceClient) GetCapabilities(ctx context.Context, in *GetCapabilitiesRequest, opts ...grpc.CallOption) (*GetCapabilitiesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetCapabilitiesResponse) + err := c.cc.Invoke(ctx, SlashCommandService_GetCapabilities_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *slashCommandServiceClient) Configure(ctx context.Context, in *ConfigureRequest, opts ...grpc.CallOption) (*ConfigureResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ConfigureResponse) + err := c.cc.Invoke(ctx, SlashCommandService_Configure_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *slashCommandServiceClient) Invoke(ctx context.Context, in *InvokeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[InvokeResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &SlashCommandService_ServiceDesc.Streams[0], SlashCommandService_Invoke_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[InvokeRequest, InvokeResponse]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type SlashCommandService_InvokeClient = grpc.ServerStreamingClient[InvokeResponse] + +func (c *slashCommandServiceClient) Render(ctx context.Context, in *RenderRequest, opts ...grpc.CallOption) (*RenderResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RenderResponse) + err := c.cc.Invoke(ctx, SlashCommandService_Render_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *slashCommandServiceClient) Preview(ctx context.Context, in *PreviewRequest, opts ...grpc.CallOption) (*PreviewResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PreviewResponse) + err := c.cc.Invoke(ctx, SlashCommandService_Preview_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *slashCommandServiceClient) Describe(ctx context.Context, in *DescribeRequest, opts ...grpc.CallOption) (*DescribeResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DescribeResponse) + err := c.cc.Invoke(ctx, SlashCommandService_Describe_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// SlashCommandServiceServer is the server API for SlashCommandService service. +// All implementations must embed UnimplementedSlashCommandServiceServer +// for forward compatibility. +// +// SlashCommandService is the slash-command provider plugin protocol +// described in specifications/slashcommand/protocol.md. A slashcommand +// provider plugin exposes GetCapabilities, Configure, and Invoke; it MAY +// additionally implement Render and Preview. +type SlashCommandServiceServer interface { + // GetCapabilities returns the SlashCommandSpec for every command this + // plugin exposes, per slashcommand/protocol.md#getcapabilities. MUST be + // cheaply re-queryable and MUST NOT require a network call. + GetCapabilities(context.Context, *GetCapabilitiesRequest) (*GetCapabilitiesResponse, error) + // Configure decodes this provider's agent.hcl block, per + // slashcommand/protocol.md#configure. Errors surface as a gRPC status + // carrying a pluggableharness.tool.v1.ToolError in its structured + // detail, per grpc.md — not an in-band field on ConfigureResponse. + Configure(context.Context, *ConfigureRequest) (*ConfigureResponse, error) + // Invoke executes one direct-invoke command and streams back its + // events, per slashcommand/protocol.md#invoke. Server-streaming, + // reusing tool/protocol.md#invoke's shape verbatim — a + // non-incremental command MUST still implement this shape, emitting + // exactly one terminal `result` or `error`. Cancellation is the kernel + // closing the gRPC stream; the plugin MUST treat this as normal + // control flow, never as an error. + Invoke(*InvokeRequest, grpc.ServerStreamingServer[InvokeResponse]) error + // Render returns a RenderTree for a previously-emitted opaque payload, + // per slashcommand/protocol.md#render. MAY be implemented; if absent, + // the kernel falls back to its generic default (pretty-printed JSON + // payload). + Render(context.Context, *RenderRequest) (*RenderResponse, error) + // Preview returns a dry-run, human-readable description of what + // Invoke would do for the given call, without performing it — per + // slashcommand/protocol.md#preview. MAY be implemented; a kernel MUST + // tolerate its absence and fall back to showing the call's raw + // arguments in the plan/apply gate's permission UI. + Preview(context.Context, *PreviewRequest) (*PreviewResponse, error) + // Describe reports this plugin build's own identity — per + // slashcommand/protocol.md#describe and configuration/lock-file.md's + // dev_overrides note, this is how the kernel learns a + // dev_overrides-resolved plugin's {name, version, source, category, + // protocol_version} when there is no lock-file entry to read it from. + Describe(context.Context, *DescribeRequest) (*DescribeResponse, error) + mustEmbedUnimplementedSlashCommandServiceServer() +} + +// UnimplementedSlashCommandServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedSlashCommandServiceServer struct{} + +func (UnimplementedSlashCommandServiceServer) GetCapabilities(context.Context, *GetCapabilitiesRequest) (*GetCapabilitiesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetCapabilities not implemented") +} +func (UnimplementedSlashCommandServiceServer) Configure(context.Context, *ConfigureRequest) (*ConfigureResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Configure not implemented") +} +func (UnimplementedSlashCommandServiceServer) Invoke(*InvokeRequest, grpc.ServerStreamingServer[InvokeResponse]) error { + return status.Error(codes.Unimplemented, "method Invoke not implemented") +} +func (UnimplementedSlashCommandServiceServer) Render(context.Context, *RenderRequest) (*RenderResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Render not implemented") +} +func (UnimplementedSlashCommandServiceServer) Preview(context.Context, *PreviewRequest) (*PreviewResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Preview not implemented") +} +func (UnimplementedSlashCommandServiceServer) Describe(context.Context, *DescribeRequest) (*DescribeResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Describe not implemented") +} +func (UnimplementedSlashCommandServiceServer) mustEmbedUnimplementedSlashCommandServiceServer() {} +func (UnimplementedSlashCommandServiceServer) testEmbeddedByValue() {} + +// UnsafeSlashCommandServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to SlashCommandServiceServer will +// result in compilation errors. +type UnsafeSlashCommandServiceServer interface { + mustEmbedUnimplementedSlashCommandServiceServer() +} + +func RegisterSlashCommandServiceServer(s grpc.ServiceRegistrar, srv SlashCommandServiceServer) { + // If the following call panics, it indicates UnimplementedSlashCommandServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&SlashCommandService_ServiceDesc, srv) +} + +func _SlashCommandService_GetCapabilities_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetCapabilitiesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SlashCommandServiceServer).GetCapabilities(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SlashCommandService_GetCapabilities_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SlashCommandServiceServer).GetCapabilities(ctx, req.(*GetCapabilitiesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SlashCommandService_Configure_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ConfigureRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SlashCommandServiceServer).Configure(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SlashCommandService_Configure_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SlashCommandServiceServer).Configure(ctx, req.(*ConfigureRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SlashCommandService_Invoke_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(InvokeRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(SlashCommandServiceServer).Invoke(m, &grpc.GenericServerStream[InvokeRequest, InvokeResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type SlashCommandService_InvokeServer = grpc.ServerStreamingServer[InvokeResponse] + +func _SlashCommandService_Render_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RenderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SlashCommandServiceServer).Render(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SlashCommandService_Render_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SlashCommandServiceServer).Render(ctx, req.(*RenderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SlashCommandService_Preview_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PreviewRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SlashCommandServiceServer).Preview(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SlashCommandService_Preview_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SlashCommandServiceServer).Preview(ctx, req.(*PreviewRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SlashCommandService_Describe_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DescribeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SlashCommandServiceServer).Describe(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SlashCommandService_Describe_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SlashCommandServiceServer).Describe(ctx, req.(*DescribeRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// SlashCommandService_ServiceDesc is the grpc.ServiceDesc for SlashCommandService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var SlashCommandService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "pluggableharness.slashcommand.v1.SlashCommandService", + HandlerType: (*SlashCommandServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetCapabilities", + Handler: _SlashCommandService_GetCapabilities_Handler, + }, + { + MethodName: "Configure", + Handler: _SlashCommandService_Configure_Handler, + }, + { + MethodName: "Render", + Handler: _SlashCommandService_Render_Handler, + }, + { + MethodName: "Preview", + Handler: _SlashCommandService_Preview_Handler, + }, + { + MethodName: "Describe", + Handler: _SlashCommandService_Describe_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "Invoke", + Handler: _SlashCommandService_Invoke_Handler, + ServerStreams: true, + }, + }, + Metadata: "pluggableharness/slashcommand/v1/service.proto", +} diff --git a/pkg/slashcommand/proto/v1/slashcommand.pb.go b/pkg/slashcommand/proto/v1/slashcommand.pb.go deleted file mode 100644 index f48e780..0000000 --- a/pkg/slashcommand/proto/v1/slashcommand.pb.go +++ /dev/null @@ -1,248 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: pluggableharness/slashcommand/v1/slashcommand.proto - -// Package pluggableharness.slashcommand.v1 defines the slash-command declaration -// shape described in specifications/configuration.md §5 (and equivalently -// specifications/frontend.md §5). Declarable as an optional repeated field -// in every category's capability response: model.md §2 Capabilities, -// tool.md §2 GetSchemaResponse, context.md §2 ContextCapabilities, -// memory.md §3 MemoryCapabilities. - -package slashcommandv1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// Dispatch selects how a slash command reaches the kernel when invoked. -type Dispatch int32 - -const ( - // Zero value. Never valid for a real command; its presence on the wire - // means a caller forgot to set the field. - Dispatch_DISPATCH_UNSPECIFIED Dispatch = 0 - // Maps the command's arguments directly to a tool call's input_schema - // and dispatches through the normal Invoke/plan-apply pipeline, - // including policy evaluation. Costs no model turn; the result is - // appended to history as an ordinary tool_result. - Dispatch_DISPATCH_DIRECT_INVOKE Dispatch = 1 - // Expands SlashCommandSpec.template with the command's arguments and - // submits the result as an ordinary user_message. Costs a model turn. - Dispatch_DISPATCH_PROMPT_EXPANSION Dispatch = 2 -) - -// Enum value maps for Dispatch. -var ( - Dispatch_name = map[int32]string{ - 0: "DISPATCH_UNSPECIFIED", - 1: "DISPATCH_DIRECT_INVOKE", - 2: "DISPATCH_PROMPT_EXPANSION", - } - Dispatch_value = map[string]int32{ - "DISPATCH_UNSPECIFIED": 0, - "DISPATCH_DIRECT_INVOKE": 1, - "DISPATCH_PROMPT_EXPANSION": 2, - } -) - -func (x Dispatch) Enum() *Dispatch { - p := new(Dispatch) - *p = x - return p -} - -func (x Dispatch) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (Dispatch) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_slashcommand_v1_slashcommand_proto_enumTypes[0].Descriptor() -} - -func (Dispatch) Type() protoreflect.EnumType { - return &file_pluggableharness_slashcommand_v1_slashcommand_proto_enumTypes[0] -} - -func (x Dispatch) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use Dispatch.Descriptor instead. -func (Dispatch) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_slashcommand_v1_slashcommand_proto_rawDescGZIP(), []int{0} -} - -// SlashCommandSpec declares one slash command a plugin contributes. -type SlashCommandSpec struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The command's name, without the leading "/". MUST be unique across - // every provider in the session — a name collision at config-load time - // is a hard error (configuration.md §5). - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - // Shown in the frontend's hotkey_hints region (frontend.md §2) and - // wherever else the frontend surfaces available commands. - Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` - // How this command is dispatched when invoked. - Dispatch Dispatch `protobuf:"varint,3,opt,name=dispatch,proto3,enum=pluggableharness.slashcommand.v1.Dispatch" json:"dispatch,omitempty"` - // The tool operation to invoke. MUST be set if and only if - // dispatch == DISPATCH_DIRECT_INVOKE, and MUST name one of this same - // provider's own tool operations (tool.md §2.1) — a provider cannot - // declare a slash command that invokes another provider's tool. - ToolName *string `protobuf:"bytes,4,opt,name=tool_name,json=toolName,proto3,oneof" json:"tool_name,omitempty"` - // The prompt template to expand, using "{arg}"-style placeholders. MUST - // be set if and only if dispatch == DISPATCH_PROMPT_EXPANSION. - Template *string `protobuf:"bytes,5,opt,name=template,proto3,oneof" json:"template,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SlashCommandSpec) Reset() { - *x = SlashCommandSpec{} - mi := &file_pluggableharness_slashcommand_v1_slashcommand_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SlashCommandSpec) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SlashCommandSpec) ProtoMessage() {} - -func (x *SlashCommandSpec) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_slashcommand_v1_slashcommand_proto_msgTypes[0] - 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 SlashCommandSpec.ProtoReflect.Descriptor instead. -func (*SlashCommandSpec) Descriptor() ([]byte, []int) { - return file_pluggableharness_slashcommand_v1_slashcommand_proto_rawDescGZIP(), []int{0} -} - -func (x *SlashCommandSpec) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *SlashCommandSpec) GetDescription() string { - if x != nil { - return x.Description - } - return "" -} - -func (x *SlashCommandSpec) GetDispatch() Dispatch { - if x != nil { - return x.Dispatch - } - return Dispatch_DISPATCH_UNSPECIFIED -} - -func (x *SlashCommandSpec) GetToolName() string { - if x != nil && x.ToolName != nil { - return *x.ToolName - } - return "" -} - -func (x *SlashCommandSpec) GetTemplate() string { - if x != nil && x.Template != nil { - return *x.Template - } - return "" -} - -var File_pluggableharness_slashcommand_v1_slashcommand_proto protoreflect.FileDescriptor - -const file_pluggableharness_slashcommand_v1_slashcommand_proto_rawDesc = "" + - "\n" + - "3pluggableharness/slashcommand/v1/slashcommand.proto\x12 pluggableharness.slashcommand.v1\"\xee\x01\n" + - "\x10SlashCommandSpec\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + - "\vdescription\x18\x02 \x01(\tR\vdescription\x12F\n" + - "\bdispatch\x18\x03 \x01(\x0e2*.pluggableharness.slashcommand.v1.DispatchR\bdispatch\x12 \n" + - "\ttool_name\x18\x04 \x01(\tH\x00R\btoolName\x88\x01\x01\x12\x1f\n" + - "\btemplate\x18\x05 \x01(\tH\x01R\btemplate\x88\x01\x01B\f\n" + - "\n" + - "_tool_nameB\v\n" + - "\t_template*_\n" + - "\bDispatch\x12\x18\n" + - "\x14DISPATCH_UNSPECIFIED\x10\x00\x12\x1a\n" + - "\x16DISPATCH_DIRECT_INVOKE\x10\x01\x12\x1d\n" + - "\x19DISPATCH_PROMPT_EXPANSION\x10\x02BLZJgithub.com/pluggableharness/agent/pkg/slashcommand/proto/v1;slashcommandv1b\x06proto3" - -var ( - file_pluggableharness_slashcommand_v1_slashcommand_proto_rawDescOnce sync.Once - file_pluggableharness_slashcommand_v1_slashcommand_proto_rawDescData []byte -) - -func file_pluggableharness_slashcommand_v1_slashcommand_proto_rawDescGZIP() []byte { - file_pluggableharness_slashcommand_v1_slashcommand_proto_rawDescOnce.Do(func() { - file_pluggableharness_slashcommand_v1_slashcommand_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_slashcommand_v1_slashcommand_proto_rawDesc), len(file_pluggableharness_slashcommand_v1_slashcommand_proto_rawDesc))) - }) - return file_pluggableharness_slashcommand_v1_slashcommand_proto_rawDescData -} - -var file_pluggableharness_slashcommand_v1_slashcommand_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_pluggableharness_slashcommand_v1_slashcommand_proto_msgTypes = make([]protoimpl.MessageInfo, 1) -var file_pluggableharness_slashcommand_v1_slashcommand_proto_goTypes = []any{ - (Dispatch)(0), // 0: pluggableharness.slashcommand.v1.Dispatch - (*SlashCommandSpec)(nil), // 1: pluggableharness.slashcommand.v1.SlashCommandSpec -} -var file_pluggableharness_slashcommand_v1_slashcommand_proto_depIdxs = []int32{ - 0, // 0: pluggableharness.slashcommand.v1.SlashCommandSpec.dispatch:type_name -> pluggableharness.slashcommand.v1.Dispatch - 1, // [1:1] is the sub-list for method output_type - 1, // [1:1] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name -} - -func init() { file_pluggableharness_slashcommand_v1_slashcommand_proto_init() } -func file_pluggableharness_slashcommand_v1_slashcommand_proto_init() { - if File_pluggableharness_slashcommand_v1_slashcommand_proto != nil { - return - } - file_pluggableharness_slashcommand_v1_slashcommand_proto_msgTypes[0].OneofWrappers = []any{} - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_slashcommand_v1_slashcommand_proto_rawDesc), len(file_pluggableharness_slashcommand_v1_slashcommand_proto_rawDesc)), - NumEnums: 1, - NumMessages: 1, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_pluggableharness_slashcommand_v1_slashcommand_proto_goTypes, - DependencyIndexes: file_pluggableharness_slashcommand_v1_slashcommand_proto_depIdxs, - EnumInfos: file_pluggableharness_slashcommand_v1_slashcommand_proto_enumTypes, - MessageInfos: file_pluggableharness_slashcommand_v1_slashcommand_proto_msgTypes, - }.Build() - File_pluggableharness_slashcommand_v1_slashcommand_proto = out.File - file_pluggableharness_slashcommand_v1_slashcommand_proto_goTypes = nil - file_pluggableharness_slashcommand_v1_slashcommand_proto_depIdxs = nil -} diff --git a/pkg/slashcommand/proto/v1/types.pb.go b/pkg/slashcommand/proto/v1/types.pb.go new file mode 100644 index 0000000..6222460 --- /dev/null +++ b/pkg/slashcommand/proto/v1/types.pb.go @@ -0,0 +1,335 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/slashcommand/v1/types.proto + +package slashcommandv1 + +import ( + v12 "github.com/pluggableharness/agent/pkg/common/proto/v1" + v1 "github.com/pluggableharness/agent/pkg/schema/proto/v1" + v11 "github.com/pluggableharness/agent/pkg/tool/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// SlashCommandSpec declares one directly-invocable command this +// provider exposes — a tool-shaped operation in its own right, invoked +// via this same provider's own SlashCommandService.Invoke, never by +// naming another provider's tool operation. kind/risk/concurrency are +// pluggableharness.tool.v1 types, reused verbatim: a direct-invoke +// command flows through the identical plan/apply gate a tool call does +// (pluggableharness.plan.v1.PlanItem), so it needs the identical +// classification vocabulary, not a parallel copy of it. +type SlashCommandSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The command's name, without the leading "/". MUST be unique across + // every direct-invoke command declared by every provider in the + // session — a name collision at config-load time is a hard error + // (configuration.md §5). + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Shown in the frontend's hotkey_hints region and wherever else the + // frontend surfaces available commands. + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + // MUST — the common JSON-Schema subset per model.md §6, describing + // the shape of SlashCommandCall.arguments for this command. + InputSchema *v1.Schema `protobuf:"bytes,3,opt,name=input_schema,json=inputSchema,proto3" json:"input_schema,omitempty"` + // MUST — drives the plan/apply gate, identically to + // pluggableharness.tool.v1.ToolSchema.kind. + Kind v11.ToolKind `protobuf:"varint,4,opt,name=kind,proto3,enum=pluggableharness.tool.v1.ToolKind" json:"kind,omitempty"` + // MUST — see pluggableharness.tool.v1.RiskClass. + Risk v11.RiskClass `protobuf:"varint,5,opt,name=risk,proto3,enum=pluggableharness.tool.v1.RiskClass" json:"risk,omitempty"` + // MUST, except MUST NOT be meaningfully set for TOOL_KIND_INTERACTIVE. + Concurrency *v11.ConcurrencySpec `protobuf:"bytes,6,opt,name=concurrency,proto3" json:"concurrency,omitempty"` + // MUST — true if Invoke may emit intermediate SlashCommandEvents + // (output_chunk, progress, partial_result) before the terminal event; + // false if Invoke always emits exactly one terminal event with no + // lead-up. + Streaming bool `protobuf:"varint,7,opt,name=streaming,proto3" json:"streaming,omitempty"` + // SHOULD — the deadline the kernel applies to Invoke for this command + // absent an agent.hcl override. Absent means the kernel's global + // default applies instead (configuration/settings-and-global.md). + DefaultTimeout *durationpb.Duration `protobuf:"bytes,8,opt,name=default_timeout,json=defaultTimeout,proto3,oneof" json:"default_timeout,omitempty"` + // True iff re-running this command with identical arguments cannot + // produce a different end state than running it once. Gates whether + // the kernel MAY auto-retry a retryable + // pluggableharness.tool.v1.ToolError for a TOOL_KIND_RESOURCE command + // — see tool/conformance.md#error-taxonomy's retry interaction, reused + // verbatim. + Idempotent bool `protobuf:"varint,9,opt,name=idempotent,proto3" json:"idempotent,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SlashCommandSpec) Reset() { + *x = SlashCommandSpec{} + mi := &file_pluggableharness_slashcommand_v1_types_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SlashCommandSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SlashCommandSpec) ProtoMessage() {} + +func (x *SlashCommandSpec) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_types_proto_msgTypes[0] + 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 SlashCommandSpec.ProtoReflect.Descriptor instead. +func (*SlashCommandSpec) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_types_proto_rawDescGZIP(), []int{0} +} + +func (x *SlashCommandSpec) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *SlashCommandSpec) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *SlashCommandSpec) GetInputSchema() *v1.Schema { + if x != nil { + return x.InputSchema + } + return nil +} + +func (x *SlashCommandSpec) GetKind() v11.ToolKind { + if x != nil { + return x.Kind + } + return v11.ToolKind(0) +} + +func (x *SlashCommandSpec) GetRisk() v11.RiskClass { + if x != nil { + return x.Risk + } + return v11.RiskClass(0) +} + +func (x *SlashCommandSpec) GetConcurrency() *v11.ConcurrencySpec { + if x != nil { + return x.Concurrency + } + return nil +} + +func (x *SlashCommandSpec) GetStreaming() bool { + if x != nil { + return x.Streaming + } + return false +} + +func (x *SlashCommandSpec) GetDefaultTimeout() *durationpb.Duration { + if x != nil { + return x.DefaultTimeout + } + return nil +} + +func (x *SlashCommandSpec) GetIdempotent() bool { + if x != nil { + return x.Idempotent + } + return false +} + +// SlashCommandCall is one request to execute a direct-invoke command, +// structurally identical to pluggableharness.tool.v1.ToolCall. +type SlashCommandCall struct { + state protoimpl.MessageState `protogen:"open.v1"` + // MUST — kernel-assigned. Echoed in every SlashCommandEvent for this + // call, for correlation. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // MUST — matches a SlashCommandSpec.name from this provider's + // GetCapabilities response. + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + // MUST — already-parsed JSON conforming to that SlashCommandSpec's + // input_schema. + Arguments *structpb.Struct `protobuf:"bytes,3,opt,name=arguments,proto3" json:"arguments,omitempty"` + // MUST be set by the kernel. Carries the session_id/turn_id this call + // executes for and the session's working_directory, identically to + // pluggableharness.tool.v1.ToolCall.call_context. + CallContext *v12.CallContext `protobuf:"bytes,4,opt,name=call_context,json=callContext,proto3" json:"call_context,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SlashCommandCall) Reset() { + *x = SlashCommandCall{} + mi := &file_pluggableharness_slashcommand_v1_types_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SlashCommandCall) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SlashCommandCall) ProtoMessage() {} + +func (x *SlashCommandCall) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_slashcommand_v1_types_proto_msgTypes[1] + 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 SlashCommandCall.ProtoReflect.Descriptor instead. +func (*SlashCommandCall) Descriptor() ([]byte, []int) { + return file_pluggableharness_slashcommand_v1_types_proto_rawDescGZIP(), []int{1} +} + +func (x *SlashCommandCall) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *SlashCommandCall) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *SlashCommandCall) GetArguments() *structpb.Struct { + if x != nil { + return x.Arguments + } + return nil +} + +func (x *SlashCommandCall) GetCallContext() *v12.CallContext { + if x != nil { + return x.CallContext + } + return nil +} + +var File_pluggableharness_slashcommand_v1_types_proto protoreflect.FileDescriptor + +const file_pluggableharness_slashcommand_v1_types_proto_rawDesc = "" + + "\n" + + ",pluggableharness/slashcommand/v1/types.proto\x12 pluggableharness.slashcommand.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/schema/v1/types.proto\x1a$pluggableharness/tool/v1/types.proto\"\xe8\x03\n" + + "\x10SlashCommandSpec\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x02 \x01(\tR\vdescription\x12E\n" + + "\finput_schema\x18\x03 \x01(\v2\".pluggableharness.schema.v1.SchemaR\vinputSchema\x126\n" + + "\x04kind\x18\x04 \x01(\x0e2\".pluggableharness.tool.v1.ToolKindR\x04kind\x127\n" + + "\x04risk\x18\x05 \x01(\x0e2#.pluggableharness.tool.v1.RiskClassR\x04risk\x12K\n" + + "\vconcurrency\x18\x06 \x01(\v2).pluggableharness.tool.v1.ConcurrencySpecR\vconcurrency\x12\x1c\n" + + "\tstreaming\x18\a \x01(\bR\tstreaming\x12G\n" + + "\x0fdefault_timeout\x18\b \x01(\v2\x19.google.protobuf.DurationH\x00R\x0edefaultTimeout\x88\x01\x01\x12\x1e\n" + + "\n" + + "idempotent\x18\t \x01(\bR\n" + + "idempotentB\x12\n" + + "\x10_default_timeout\"\xb9\x01\n" + + "\x10SlashCommandCall\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x125\n" + + "\targuments\x18\x03 \x01(\v2\x17.google.protobuf.StructR\targuments\x12J\n" + + "\fcall_context\x18\x04 \x01(\v2'.pluggableharness.common.v1.CallContextR\vcallContextBLZJgithub.com/pluggableharness/agent/pkg/slashcommand/proto/v1;slashcommandv1b\x06proto3" + +var ( + file_pluggableharness_slashcommand_v1_types_proto_rawDescOnce sync.Once + file_pluggableharness_slashcommand_v1_types_proto_rawDescData []byte +) + +func file_pluggableharness_slashcommand_v1_types_proto_rawDescGZIP() []byte { + file_pluggableharness_slashcommand_v1_types_proto_rawDescOnce.Do(func() { + file_pluggableharness_slashcommand_v1_types_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_slashcommand_v1_types_proto_rawDesc), len(file_pluggableharness_slashcommand_v1_types_proto_rawDesc))) + }) + return file_pluggableharness_slashcommand_v1_types_proto_rawDescData +} + +var file_pluggableharness_slashcommand_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_pluggableharness_slashcommand_v1_types_proto_goTypes = []any{ + (*SlashCommandSpec)(nil), // 0: pluggableharness.slashcommand.v1.SlashCommandSpec + (*SlashCommandCall)(nil), // 1: pluggableharness.slashcommand.v1.SlashCommandCall + (*v1.Schema)(nil), // 2: pluggableharness.schema.v1.Schema + (v11.ToolKind)(0), // 3: pluggableharness.tool.v1.ToolKind + (v11.RiskClass)(0), // 4: pluggableharness.tool.v1.RiskClass + (*v11.ConcurrencySpec)(nil), // 5: pluggableharness.tool.v1.ConcurrencySpec + (*durationpb.Duration)(nil), // 6: google.protobuf.Duration + (*structpb.Struct)(nil), // 7: google.protobuf.Struct + (*v12.CallContext)(nil), // 8: pluggableharness.common.v1.CallContext +} +var file_pluggableharness_slashcommand_v1_types_proto_depIdxs = []int32{ + 2, // 0: pluggableharness.slashcommand.v1.SlashCommandSpec.input_schema:type_name -> pluggableharness.schema.v1.Schema + 3, // 1: pluggableharness.slashcommand.v1.SlashCommandSpec.kind:type_name -> pluggableharness.tool.v1.ToolKind + 4, // 2: pluggableharness.slashcommand.v1.SlashCommandSpec.risk:type_name -> pluggableharness.tool.v1.RiskClass + 5, // 3: pluggableharness.slashcommand.v1.SlashCommandSpec.concurrency:type_name -> pluggableharness.tool.v1.ConcurrencySpec + 6, // 4: pluggableharness.slashcommand.v1.SlashCommandSpec.default_timeout:type_name -> google.protobuf.Duration + 7, // 5: pluggableharness.slashcommand.v1.SlashCommandCall.arguments:type_name -> google.protobuf.Struct + 8, // 6: pluggableharness.slashcommand.v1.SlashCommandCall.call_context:type_name -> pluggableharness.common.v1.CallContext + 7, // [7:7] is the sub-list for method output_type + 7, // [7:7] 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 +} + +func init() { file_pluggableharness_slashcommand_v1_types_proto_init() } +func file_pluggableharness_slashcommand_v1_types_proto_init() { + if File_pluggableharness_slashcommand_v1_types_proto != nil { + return + } + file_pluggableharness_slashcommand_v1_types_proto_msgTypes[0].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_slashcommand_v1_types_proto_rawDesc), len(file_pluggableharness_slashcommand_v1_types_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_slashcommand_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_slashcommand_v1_types_proto_depIdxs, + MessageInfos: file_pluggableharness_slashcommand_v1_types_proto_msgTypes, + }.Build() + File_pluggableharness_slashcommand_v1_types_proto = out.File + file_pluggableharness_slashcommand_v1_types_proto_goTypes = nil + file_pluggableharness_slashcommand_v1_types_proto_depIdxs = nil +} diff --git a/pkg/slashcommand/server.go b/pkg/slashcommand/server.go new file mode 100644 index 0000000..742b78e --- /dev/null +++ b/pkg/slashcommand/server.go @@ -0,0 +1,166 @@ +package slashcommand + +import ( + "context" + "errors" + "fmt" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" + slashcommandv1 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" +) + +// callbackCtxKey is the unexported context key ContextWithCallback and +// CallbackFromContext use, per .claude/rules/go-architecture.md's "Context +// keys are an unexported type" rule. Deliberately a distinct type from +// pkg/tool's own callbackCtxKey — context keys are per-package by +// convention, not shared across category SDKs. +type callbackCtxKey struct{} + +// ContextWithCallback returns a copy of ctx carrying cb, retrievable with +// CallbackFromContext. Service attaches its own *plugin.Callback to the +// context it passes into every Provider method, so an implementation that +// needs to call back into the kernel (Emit a progress update outside the +// Invoke stream, RunSession for a spawn_subagent-shaped command, ...) can +// reach it without every Provider method signature threading a +// *plugin.Callback through by hand. +func ContextWithCallback(ctx context.Context, cb *plugin.Callback) context.Context { + return context.WithValue(ctx, callbackCtxKey{}, cb) +} + +// CallbackFromContext retrieves the *plugin.Callback ContextWithCallback +// attached to ctx, if any. +func CallbackFromContext(ctx context.Context) (*plugin.Callback, bool) { + cb, ok := ctx.Value(callbackCtxKey{}).(*plugin.Callback) + return cb, ok +} + +// Service adapts a Provider onto the generated +// slashcommandv1.SlashCommandServiceServer, implementing plugin.Service so +// it can be passed to plugin.Config.Services. +type Service struct { + slashcommandv1.UnimplementedSlashCommandServiceServer + + identity plugin.Identity + callback *plugin.Callback + impl Provider +} + +var _ plugin.Service = (*Service)(nil) +var _ slashcommandv1.SlashCommandServiceServer = (*Service)(nil) + +// NewService builds a *Service adapting p onto SlashCommandServiceServer. +// identity is this plugin build's own self-reported identity, returned +// verbatim by Describe; callback is the lazily-dialed kernel-callback +// handle attached to every context Service passes into p (see +// ContextWithCallback). +func NewService(p Provider, identity plugin.Identity, callback *plugin.Callback) *Service { + return &Service{identity: identity, callback: callback, impl: p} +} + +// Register registers SlashCommandService on g, satisfying plugin.Service. +func (s *Service) Register(g *grpc.Server) { + slashcommandv1.RegisterSlashCommandServiceServer(g, s) +} + +// ctx returns base with this Service's callback attached, for handing to +// the wrapped Provider. +func (s *Service) ctx(base context.Context) context.Context { + return ContextWithCallback(base, s.callback) +} + +// GetCapabilities implements slashcommandv1.SlashCommandServiceServer. +func (s *Service) GetCapabilities(ctx context.Context, _ *slashcommandv1.GetCapabilitiesRequest) (*slashcommandv1.GetCapabilitiesResponse, error) { + resp, err := BuildGetCapabilitiesResponse(s.ctx(ctx), s.impl) + if err != nil { + return nil, unknownStatusError(err) + } + return resp, nil +} + +// Configure implements slashcommandv1.SlashCommandServiceServer. +func (s *Service) Configure(ctx context.Context, req *slashcommandv1.ConfigureRequest) (*slashcommandv1.ConfigureResponse, error) { + cfg := structToMap(req.GetConfig()) + if err := s.impl.Configure(s.ctx(ctx), cfg); err != nil { + return nil, configureStatusError(err) + } + return &slashcommandv1.ConfigureResponse{}, nil +} + +// Invoke implements slashcommandv1.SlashCommandServiceServer. +// Server-streaming: it decodes the request's Call, hands it and a *Stream +// to the wrapped Provider, and treats a cancelled context as normal +// control flow rather than a failed RPC, per +// docs/specifications/slashcommand/README.md#transport--lifecycle. +func (s *Service) Invoke(req *slashcommandv1.InvokeRequest, grpcStream slashcommandv1.SlashCommandService_InvokeServer) error { + call, err := fromProtoCall(req.GetCall()) + if err != nil { + return invalidArgumentStatusError("slashcommand: invoke: %v", err) + } + + st := newStream(grpcStream) + invokeErr := s.impl.Invoke(s.ctx(grpcStream.Context()), call, st) + + switch { + case invokeErr == nil: + if !st.closedTerminal() { + return fmt.Errorf("slashcommand: invoke: %s: provider returned without sending a terminal result or error event", call.Name) + } + return nil + case errors.Is(invokeErr, context.Canceled), status.Code(invokeErr) == codes.Canceled: + // Cancellation is normal control flow (README.md#transport--lifecycle), + // never surfaced as an application error. + return nil + default: + return unknownStatusError(invokeErr) + } +} + +// Render implements slashcommandv1.SlashCommandServiceServer. Returns +// codes.Unimplemented if the wrapped Provider does not additionally +// implement Renderer, per +// docs/specifications/slashcommand/protocol.md#render's "MAY be +// implemented". +func (s *Service) Render(ctx context.Context, req *slashcommandv1.RenderRequest) (*slashcommandv1.RenderResponse, error) { + r, ok := s.impl.(Renderer) + if !ok { + return nil, status.Error(codes.Unimplemented, "slashcommand: render not implemented by this provider") + } + tree, err := r.Render(s.ctx(ctx), req.GetPayload(), req.GetSchemaVersion()) + if err != nil { + return nil, unknownStatusError(err) + } + return &slashcommandv1.RenderResponse{Tree: tree}, nil +} + +// Preview implements slashcommandv1.SlashCommandServiceServer. Returns +// codes.Unimplemented if the wrapped Provider does not additionally +// implement Previewer, per +// docs/specifications/slashcommand/protocol.md#preview's "MAY be +// implemented". +func (s *Service) Preview(ctx context.Context, req *slashcommandv1.PreviewRequest) (*slashcommandv1.PreviewResponse, error) { + p, ok := s.impl.(Previewer) + if !ok { + return nil, status.Error(codes.Unimplemented, "slashcommand: preview not implemented by this provider") + } + call, err := fromProtoCall(req.GetCall()) + if err != nil { + return nil, invalidArgumentStatusError("slashcommand: preview: %v", err) + } + tree, err := p.Preview(s.ctx(ctx), call) + if err != nil { + return nil, unknownStatusError(err) + } + return &slashcommandv1.PreviewResponse{Preview: tree}, nil +} + +// Describe implements slashcommandv1.SlashCommandServiceServer directly +// from s.identity, per +// docs/specifications/slashcommand/protocol.md#describe. +func (s *Service) Describe(context.Context, *slashcommandv1.DescribeRequest) (*slashcommandv1.DescribeResponse, error) { + return &slashcommandv1.DescribeResponse{Producer: s.identity.ProducerRef(commonv1.Category_CATEGORY_SLASHCOMMAND)}, nil +} diff --git a/pkg/slashcommand/server_test.go b/pkg/slashcommand/server_test.go new file mode 100644 index 0000000..1fdb2f2 --- /dev/null +++ b/pkg/slashcommand/server_test.go @@ -0,0 +1,417 @@ +package slashcommand_test + +import ( + "context" + "errors" + "io" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" + "github.com/pluggableharness/agent/pkg/slashcommand" + slashcommandv1 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" + "github.com/pluggableharness/agent/pkg/tool" +) + +func TestServiceGetCapabilities(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + capabilitiesFunc: func(context.Context) ([]*slashcommand.Spec, error) { + return []*slashcommand.Spec{validSpec("deploy")}, nil + }, + } + client := newTestClient(t, p) + + resp, err := client.GetCapabilities(t.Context(), &slashcommandv1.GetCapabilitiesRequest{}) + if err != nil { + t.Fatalf("GetCapabilities: %v", err) + } + if len(resp.GetCommands()) != 1 || resp.GetCommands()[0].GetName() != "deploy" { + t.Errorf("Commands = %v", resp.GetCommands()) + } +} + +func TestServiceGetCapabilitiesError(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + capabilitiesFunc: func(context.Context) ([]*slashcommand.Spec, error) { return nil, errors.New("boom") }, + } + client := newTestClient(t, p) + + _, err := client.GetCapabilities(t.Context(), &slashcommandv1.GetCapabilitiesRequest{}) + st, ok := status.FromError(err) + if !ok { + t.Fatalf("GetCapabilities error is not a *status.Status: %v", err) + } + if st.Code() != codes.Internal { + t.Errorf("code = %v, want %v", st.Code(), codes.Internal) + } +} + +func TestServiceConfigure(t *testing.T) { + t.Parallel() + + var gotConfig map[string]any + p := &fakeProvider{ + configureFunc: func(_ context.Context, config map[string]any) error { + gotConfig = config + return nil + }, + } + client := newTestClient(t, p) + + cfg, err := structpb.NewStruct(map[string]any{"channel": "#releases"}) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + if _, err := client.Configure(t.Context(), &slashcommandv1.ConfigureRequest{Config: cfg}); err != nil { + t.Fatalf("Configure: %v", err) + } + if gotConfig["channel"] != "#releases" { + t.Errorf("Provider.Configure received config = %v", gotConfig) + } +} + +func TestServiceConfigureRejectsWithError(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + configureFunc: func(context.Context, map[string]any) error { + return &tool.Error{Category: tool.ErrorCategoryInvalidArguments, Message: "missing channel", Retryable: false} + }, + } + client := newTestClient(t, p) + + _, err := client.Configure(t.Context(), &slashcommandv1.ConfigureRequest{}) + st, ok := status.FromError(err) + if !ok { + t.Fatalf("Configure error is not a *status.Status: %v", err) + } + if st.Code() != codes.InvalidArgument { + t.Errorf("code = %v, want %v", st.Code(), codes.InvalidArgument) + } + if st.Message() != "missing channel" { + t.Errorf("message = %q, want %q", st.Message(), "missing channel") + } +} + +func TestServiceConfigureGenericErrorDefaultsInvalidArgument(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + configureFunc: func(context.Context, map[string]any) error { return errors.New("decode failed") }, + } + client := newTestClient(t, p) + + _, err := client.Configure(t.Context(), &slashcommandv1.ConfigureRequest{}) + st, ok := status.FromError(err) + if !ok { + t.Fatalf("Configure error is not a *status.Status: %v", err) + } + if st.Code() != codes.InvalidArgument { + t.Errorf("code = %v, want %v", st.Code(), codes.InvalidArgument) + } +} + +func TestServiceInvokeStreamsEvents(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + invokeFunc: func(_ context.Context, call *slashcommand.Call, stream *slashcommand.Stream) error { + if call.Name != "deploy" { + t.Errorf("call.Name = %q, want %q", call.Name, "deploy") + } + if err := stream.Send(slashcommand.NewOutputChunkEvent(tool.OutputStreamStdout, []byte("hello"))); err != nil { + return err + } + return stream.Send(slashcommand.NewResultEvent(map[string]any{"ok": true})) + }, + } + client := newTestClient(t, p) + + args, err := structpb.NewStruct(map[string]any{"env": "prod"}) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + stream, err := client.Invoke(t.Context(), &slashcommandv1.InvokeRequest{Call: &slashcommandv1.SlashCommandCall{ + Id: "call-1", + Name: "deploy", + Arguments: args, + CallContext: &commonv1.CallContext{SessionId: "s1", TurnId: "t1", WorkingDirectory: "/work"}, + }}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + + var events []*slashcommandv1.SlashCommandEvent + for { + resp, err := stream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("stream.Recv: %v", err) + } + events = append(events, resp.GetEvent()) + } + + if len(events) != 2 { + t.Fatalf("got %d events, want 2", len(events)) + } + if events[0].GetOutputChunk() == nil || string(events[0].GetOutputChunk().GetData()) != "hello" { + t.Errorf("events[0] = %v, want output_chunk %q", events[0], "hello") + } + if events[1].GetResult() == nil || !events[1].GetResult().GetPayload().AsMap()["ok"].(bool) { + t.Errorf("events[1] = %v, want result ok=true", events[1]) + } +} + +func TestServiceInvokeCancellationIsNotSurfacedAsError(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + invokeFunc: func(context.Context, *slashcommand.Call, *slashcommand.Stream) error { + // Simulate a Provider that detects cancellation itself and + // returns context.Canceled rather than sending a terminal + // event — README.md#transport--lifecycle: cancellation is + // normal control flow, never surfaced as an application + // error. + return context.Canceled + }, + } + client := newTestClient(t, p) + + stream, err := client.Invoke(t.Context(), &slashcommandv1.InvokeRequest{Call: &slashcommandv1.SlashCommandCall{Id: "c", Name: "op"}}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + _, err = stream.Recv() + if !errors.Is(err, io.EOF) { + t.Fatalf("stream.Recv() after a context.Canceled Invoke = %v, want io.EOF (a clean close, not a failed RPC)", err) + } +} + +func TestServiceInvokeGenericErrorMapsToUnknown(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + invokeFunc: func(context.Context, *slashcommand.Call, *slashcommand.Stream) error { + return errors.New("provider panic recovered") + }, + } + client := newTestClient(t, p) + + stream, err := client.Invoke(t.Context(), &slashcommandv1.InvokeRequest{Call: &slashcommandv1.SlashCommandCall{Id: "c", Name: "op"}}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + _, err = stream.Recv() + st, ok := status.FromError(err) + if !ok { + t.Fatalf("stream.Recv() error is not a *status.Status: %v", err) + } + if st.Code() != codes.Internal { + t.Errorf("code = %v, want %v (ErrorCategoryUnknown maps to codes.Internal)", st.Code(), codes.Internal) + } +} + +func TestServiceInvokeInvalidCallRejected(t *testing.T) { + t.Parallel() + + client := newTestClient(t, &fakeProvider{}) + + stream, err := client.Invoke(t.Context(), &slashcommandv1.InvokeRequest{Call: nil}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + _, err = stream.Recv() + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("stream.Recv() code = %v, want %v", status.Code(err), codes.InvalidArgument) + } +} + +func TestServiceInvokeWithoutTerminalEventFails(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + invokeFunc: func(context.Context, *slashcommand.Call, *slashcommand.Stream) error { return nil }, + } + client := newTestClient(t, p) + + stream, err := client.Invoke(t.Context(), &slashcommandv1.InvokeRequest{Call: &slashcommandv1.SlashCommandCall{Id: "c", Name: "op"}}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + _, err = stream.Recv() + if err == nil { + t.Fatal("stream.Recv(): want an error (provider never sent a terminal event)") + } +} + +func TestServiceInvokeErrorEventTerminates(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + invokeFunc: func(_ context.Context, _ *slashcommand.Call, stream *slashcommand.Stream) error { + te, err := tool.NewError(tool.ErrorCategoryNotFound, "no such environment", false, nil) + if err != nil { + return err + } + return stream.Send(slashcommand.NewErrorEvent(te)) + }, + } + client := newTestClient(t, p) + + stream, err := client.Invoke(t.Context(), &slashcommandv1.InvokeRequest{Call: &slashcommandv1.SlashCommandCall{Id: "c", Name: "op"}}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + resp, err := stream.Recv() + if err != nil { + t.Fatalf("stream.Recv: %v", err) + } + if resp.GetEvent().GetError() == nil || resp.GetEvent().GetError().GetMessage() != "no such environment" { + t.Errorf("event = %v, want error \"no such environment\"", resp.GetEvent()) + } + if _, err := stream.Recv(); !errors.Is(err, io.EOF) { + t.Errorf("stream.Recv() after terminal error event = %v, want io.EOF", err) + } +} + +func TestServiceDescribe(t *testing.T) { + t.Parallel() + + client := newTestClient(t, &fakeProvider{}) + + resp, err := client.Describe(t.Context(), &slashcommandv1.DescribeRequest{}) + if err != nil { + t.Fatalf("Describe: %v", err) + } + producer := resp.GetProducer() + if producer.GetName() != "fake-slashcommand" || producer.GetVersion() != "0.0.1" { + t.Errorf("producer = %v, want name=fake-slashcommand version=0.0.1", producer) + } + if producer.GetCategory() != commonv1.Category_CATEGORY_SLASHCOMMAND { + t.Errorf("producer.Category = %v, want CATEGORY_SLASHCOMMAND", producer.GetCategory()) + } +} + +func TestServiceRenderUnimplementedWithoutRenderer(t *testing.T) { + t.Parallel() + + client := newTestClient(t, &fakeProvider{}) + + _, err := client.Render(t.Context(), &slashcommandv1.RenderRequest{}) + if status.Code(err) != codes.Unimplemented { + t.Fatalf("Render() code = %v, want %v", status.Code(err), codes.Unimplemented) + } +} + +func TestServicePreviewUnimplementedWithoutPreviewer(t *testing.T) { + t.Parallel() + + client := newTestClient(t, &fakeProvider{}) + + _, err := client.Preview(t.Context(), &slashcommandv1.PreviewRequest{Call: &slashcommandv1.SlashCommandCall{Id: "c", Name: "op"}}) + if status.Code(err) != codes.Unimplemented { + t.Fatalf("Preview() code = %v, want %v", status.Code(err), codes.Unimplemented) + } +} + +func TestServiceRenderAndPreview(t *testing.T) { + t.Parallel() + + base := &fakeProvider{} + p := &fakeFullProvider{ + fakeProvider: base, + renderFunc: func(_ context.Context, payload []byte, schemaVersion string) (*renderv1.RenderTree, error) { + if string(payload) != "raw" || schemaVersion != "v1" { + t.Errorf("Render(%q, %q)", payload, schemaVersion) + } + return &renderv1.RenderTree{Root: &renderv1.RenderNode{}}, nil + }, + previewFunc: func(_ context.Context, call *slashcommand.Call) (*renderv1.RenderTree, error) { + if call.Name != "deploy" { + t.Errorf("Preview call.Name = %q, want %q", call.Name, "deploy") + } + return &renderv1.RenderTree{Root: &renderv1.RenderNode{}}, nil + }, + } + client := newTestClient(t, p) + + if _, err := client.Render(t.Context(), &slashcommandv1.RenderRequest{Payload: []byte("raw"), SchemaVersion: "v1"}); err != nil { + t.Fatalf("Render: %v", err) + } + if _, err := client.Preview(t.Context(), &slashcommandv1.PreviewRequest{Call: &slashcommandv1.SlashCommandCall{Id: "c", Name: "deploy"}}); err != nil { + t.Fatalf("Preview: %v", err) + } +} + +func TestServicePreviewInvalidCallRejected(t *testing.T) { + t.Parallel() + + p := &fakeFullProvider{ + fakeProvider: &fakeProvider{}, + previewFunc: func(context.Context, *slashcommand.Call) (*renderv1.RenderTree, error) { + t.Fatal("Preview should not be called for a nil SlashCommandCall") + return nil, nil + }, + } + client := newTestClient(t, p) + + _, err := client.Preview(t.Context(), &slashcommandv1.PreviewRequest{Call: nil}) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("Preview() code = %v, want %v", status.Code(err), codes.InvalidArgument) + } +} + +func TestContextWithCallbackRoundTrip(t *testing.T) { + t.Parallel() + + cb := plugin.NewCallback() + ctx := slashcommand.ContextWithCallback(t.Context(), cb) + + got, ok := slashcommand.CallbackFromContext(ctx) + if !ok || got != cb { + t.Errorf("CallbackFromContext = (%v, %v), want (%v, true)", got, ok, cb) + } + + if _, ok := slashcommand.CallbackFromContext(t.Context()); ok { + t.Error("CallbackFromContext on a plain context: want ok=false") + } +} + +func TestServiceInvokeSeesCallback(t *testing.T) { + t.Parallel() + + var sawCallback bool + p := &fakeProvider{ + invokeFunc: func(ctx context.Context, _ *slashcommand.Call, stream *slashcommand.Stream) error { + _, sawCallback = slashcommand.CallbackFromContext(ctx) + return stream.Send(slashcommand.NewResultEvent(nil)) + }, + } + client := newTestClient(t, p) + + stream, err := client.Invoke(t.Context(), &slashcommandv1.InvokeRequest{Call: &slashcommandv1.SlashCommandCall{Id: "c", Name: "op"}}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + for { + if _, err := stream.Recv(); errors.Is(err, io.EOF) { + break + } else if err != nil { + t.Fatalf("stream.Recv: %v", err) + } + } + if !sawCallback { + t.Error("Provider.Invoke's context did not carry the Service's *plugin.Callback") + } +} diff --git a/pkg/slashcommand/slashcommand.go b/pkg/slashcommand/slashcommand.go new file mode 100644 index 0000000..3e23a93 --- /dev/null +++ b/pkg/slashcommand/slashcommand.go @@ -0,0 +1,238 @@ +package slashcommand + +import ( + "context" + "time" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" + schemav1 "github.com/pluggableharness/agent/pkg/schema/proto/v1" + "github.com/pluggableharness/agent/pkg/tool" +) + +// Spec declares one directly-invocable command a provider exposes, per +// docs/specifications/slashcommand/data-types.md#slashcommandspec. Kind, +// Risk, and Concurrency are tool.Kind/tool.RiskClass/tool.ConcurrencySpec — +// reused verbatim from pkg/tool, never redeclared here; see doc.go. Unlike +// tool.Schema, Spec has no OutputSchema field at all: a direct-invoke +// command is never presented to the model as a callable tool — it +// dispatches without a model turn — so there is no LLM-facing +// structured-output contract to validate its result against. +type Spec struct { + // Name MUST be set — the command's name, without the leading "/". + // MUST be unique across every direct-invoke command declared by + // every provider in the session; a name collision at config-load + // time is a hard error the kernel enforces, not this package. + Name string + // Description MUST be set — shown in the frontend's hotkey_hints + // region and wherever else the frontend surfaces available + // commands. + Description string + // InputSchema MUST be set — the common JSON-Schema subset (built + // with pkg/schema) describing Call.Arguments's shape for this + // command. + InputSchema *schemav1.Schema + // Kind MUST be set — drives the plan/apply gate identically to + // tool.Schema.Kind. Reused verbatim from pkg/tool. + Kind tool.Kind + // Risk MUST be set — see tool.RiskClass. MUST be + // tool.RiskClassReadOnly for tool.KindDataSource/tool.KindInteractive; + // MUST be one of low/moderate/high/critical for tool.KindResource. + // Reused verbatim from pkg/tool. + Risk tool.RiskClass + // Concurrency MUST be set for every Kind except tool.KindInteractive, + // for which it MUST be nil. Reused verbatim from pkg/tool. + Concurrency *tool.ConcurrencySpec + // Streaming MUST be set — true if Invoke may emit intermediate + // events (output_chunk, progress, partial_result) before the + // terminal event; false if Invoke always emits exactly one + // terminal event with no lead-up. + Streaming bool + // DefaultTimeout SHOULD be set — the deadline the kernel applies to + // Invoke for this command absent an agent.hcl override. The zero + // value means unset: the kernel's own global default applies + // instead. + DefaultTimeout time.Duration + // Idempotent MUST be set — true iff re-running this command with + // identical arguments cannot produce a different end state than + // running it once. Gates whether the kernel MAY auto-retry a + // retryable tool.Error for a tool.KindResource command — see + // docs/specifications/tool/conformance.md#the-idempotent--retry-interaction, + // reused verbatim here per + // docs/specifications/slashcommand/conformance.md. + Idempotent bool +} + +// Call is one request to execute a direct-invoke command, per +// docs/specifications/slashcommand/data-types.md#slashcommandcall--slashcommandevent. +// Distinct from tool.Call: a Call names a Spec.Name from this same +// provider's own Capabilities, never another provider's tool operation. +type Call struct { + // ID is kernel-assigned; echoed in every Event for this call. + ID string + // Name matches a Spec.Name from this provider's Capabilities + // response. + Name string + // Arguments is already-parsed JSON conforming to that Spec's + // InputSchema. + Arguments map[string]any + // CallContext is always set by the kernel. Its WorkingDirectory is + // the cwd this call MUST resolve any relative-path argument + // against; its SessionId/TurnId are what a Provider echoes back on + // its own kernel-callback Emit/Log calls for correlation. See + // docs/specifications/slashcommand/protocol.md#invoke. + CallContext *commonv1.CallContext +} + +// OutputChunkEvent carries one slice of raw stdout/stderr-shaped output +// from a process-backed command. Stream is tool.OutputStream, reused +// verbatim from pkg/tool. +type OutputChunkEvent struct { + Stream tool.OutputStream + Data []byte +} + +// ProgressEvent carries a human-readable status update for a long-running +// call. +type ProgressEvent struct { + Message string + // FractionComplete is how far through the operation this call is, + // in [0.0, 1.0]. nil means the provider cannot estimate completion + // fraction. + FractionComplete *float64 +} + +// PartialResultEvent carries incremental structured output before the +// terminal result, e.g. progress lines as emitted. +type PartialResultEvent struct { + Payload map[string]any +} + +// ExitStatusEvent carries a process-backed command's child process exit +// information. Process-backed commands only — a Provider for a +// non-process-backed command MUST NOT emit this. At most one per Invoke +// stream. +type ExitStatusEvent struct { + ExitCode int32 + // Signal is the signal that terminated the child process, if any. + // nil means the process exited normally. + Signal *string +} + +// Event is one message a Provider's Invoke sends via *Stream, per +// docs/specifications/slashcommand/data-types.md#slashcommandcall--slashcommandevent. +// Exactly one field is set; construct one with NewOutputChunkEvent, +// NewProgressEvent, NewPartialResultEvent, NewExitStatusEvent, +// NewResultEvent, or NewErrorEvent rather than a struct literal — see +// stream.go for the ordering, cardinality, and terminal-event contract +// *Stream.Send enforces. Result and Error are tool.Result/tool.Error, +// reused verbatim from pkg/tool per data-types.md#reused-toolv1-types — +// this type does not redeclare them. +type Event struct { + OutputChunk *OutputChunkEvent + Progress *ProgressEvent + PartialResult *PartialResultEvent + ExitStatus *ExitStatusEvent + Result *tool.Result + Error *tool.Error +} + +// NewOutputChunkEvent builds an Event carrying one output chunk. +func NewOutputChunkEvent(stream tool.OutputStream, data []byte) *Event { + return &Event{OutputChunk: &OutputChunkEvent{Stream: stream, Data: data}} +} + +// NewProgressEvent builds an Event carrying a progress update. +// fractionComplete may be nil. +func NewProgressEvent(message string, fractionComplete *float64) *Event { + return &Event{Progress: &ProgressEvent{Message: message, FractionComplete: fractionComplete}} +} + +// NewPartialResultEvent builds an Event carrying incremental structured +// output. +func NewPartialResultEvent(payload map[string]any) *Event { + return &Event{PartialResult: &PartialResultEvent{Payload: payload}} +} + +// NewExitStatusEvent builds an Event carrying a child process's exit +// status. signal may be nil. +func NewExitStatusEvent(exitCode int32, signal *string) *Event { + return &Event{ExitStatus: &ExitStatusEvent{ExitCode: exitCode, Signal: signal}} +} + +// NewResultEvent builds an Event carrying the terminal, successful result, +// wrapping payload in a tool.Result — reused verbatim from pkg/tool. +func NewResultEvent(payload map[string]any) *Event { + return &Event{Result: &tool.Result{Payload: payload}} +} + +// NewErrorEvent builds an Event carrying the terminal, failed result. err +// is a *tool.Error — reused verbatim from pkg/tool; construct one with +// tool.NewError. +func NewErrorEvent(err *tool.Error) *Event { + return &Event{Error: err} +} + +// Provider is the interface a slash-command plugin author implements; +// NewService adapts it onto the generated +// slashcommandv1.SlashCommandServiceServer. +type Provider interface { + // Capabilities returns the Spec for every direct-invoke command this + // plugin exposes, per + // docs/specifications/slashcommand/protocol.md#getcapabilities. MUST + // be cheaply re-queryable and MUST NOT make a network call. + Capabilities(ctx context.Context) ([]*Spec, error) + // Configure decodes and validates this provider's agent.hcl block, + // already decoded from JSON into config. MUST reject with an error + // on a missing required field rather than deferring failure to the + // first Invoke. A returned *tool.Error is surfaced with its own + // category/message; any other error defaults to + // tool.ErrorCategoryInvalidArguments. + Configure(ctx context.Context, config map[string]any) error + // Invoke executes call, sending zero or more non-terminal events and + // exactly one terminal event (built with NewResultEvent or + // NewErrorEvent) via stream before returning. Returning a nil error + // without having sent a terminal event is a Provider bug the + // adapter surfaces as a failed RPC. See stream.go for the full + // contract. + Invoke(ctx context.Context, call *Call, stream *Stream) error +} + +// Renderer is an optional interface a Provider MAY additionally implement +// to render a previously-emitted opaque payload as a RenderTree, per +// docs/specifications/slashcommand/protocol.md#render. If a Provider does +// not implement Renderer, the kernel falls back to its generic default +// (pretty-printed JSON payload). +type Renderer interface { + Render(ctx context.Context, payload []byte, schemaVersion string) (*renderv1.RenderTree, error) +} + +// Previewer is an optional interface a Provider MAY additionally implement +// to describe, without executing, what Invoke(call) would do, per +// docs/specifications/slashcommand/protocol.md#preview. Producing a +// preview MUST NOT mutate anything and MUST be side-effect-free; a +// Provider unable to satisfy that for a given command MUST NOT implement +// Previewer for it. If a Provider does not implement Previewer, a kernel +// falls back to showing the call's raw arguments in the plan/apply gate's +// permission UI. +type Previewer interface { + Preview(ctx context.Context, call *Call) (*renderv1.RenderTree, error) +} + +// ConfigSchemaProvider is an optional interface a Provider MAY implement +// to advertise the ConfigSchema (built with pkg/config) the kernel decodes +// its agent.hcl provider block against before ever calling Configure. A +// Provider that takes no configuration simply does not implement this +// interface. +type ConfigSchemaProvider interface { + ConfigSchema() (*configv1.ConfigSchema, error) +} + +// HookPointProvider is an optional interface a Provider MAY implement to +// advertise which of the eight dispatchable hook points its +// HookSubscriberService subscribes to, per +// docs/specifications/slashcommand/protocol.md#getcapabilities. +type HookPointProvider interface { + SupportedHookPoints() []commonv1.HookPoint +} diff --git a/pkg/slashcommand/stream.go b/pkg/slashcommand/stream.go new file mode 100644 index 0000000..45fe647 --- /dev/null +++ b/pkg/slashcommand/stream.go @@ -0,0 +1,126 @@ +package slashcommand + +import ( + "context" + "errors" + "sync" + + slashcommandv1 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" +) + +// Sentinel errors returned by Stream.Send. +var ( + // ErrStreamClosed is returned by Send once a terminal Result or + // Error event has already been sent — the stream contract + // (docs/specifications/slashcommand/protocol.md#invoke, reusing + // tool/protocol.md#invoke verbatim: "exactly one of result or error + // MUST close the stream") forbids sending anything after that. + ErrStreamClosed = errors.New("slashcommand: invoke stream already closed by a terminal result or error event") + // ErrResultAfterCancel is returned by Send when asked to send a + // success Result event after the stream's context has already been + // cancelled. Per tool/protocol.md#invoke, reused verbatim: "A + // plugin MUST NOT synthesize a result claiming full success after a + // cancelled operation." — send a partial_result/output_chunk + // best-effort report, or an error event with + // tool.ErrorCategoryCancelled, instead. + ErrResultAfterCancel = errors.New("slashcommand: cannot send a success result after the stream's context was canceled") + // ErrDuplicateExitStatus is returned by Send on a second + // exit_status event within one stream. + // docs/specifications/slashcommand/data-types.md#slashcommandcall--slashcommandevent: + // "exit_status MAY appear at most once." + ErrDuplicateExitStatus = errors.New("slashcommand: exit_status may appear at most once per invoke stream") +) + +// Stream is the cancellation-safe sender a Provider's Invoke uses to emit +// Events, per docs/specifications/slashcommand/protocol.md#invoke. It +// mirrors pkg/tool.Stream's discipline exactly — the two categories share +// the identical stream contract per data-types.md — but is its own type +// because SlashCommandCall/SlashCommandEvent are distinct generated +// messages from ToolCall/ToolEvent, so tool.Stream cannot be reused +// directly. It enforces the parts of the Invoke stream contract that are +// mechanically checkable from the sequence of Send calls alone: +// +// - exactly one of a terminal Result or Error event closes the stream; +// any Send after that returns ErrStreamClosed. +// - exit_status appears at most once; a second one returns +// ErrDuplicateExitStatus. +// - a success Result is refused once the stream's own context has been +// cancelled, so a Provider cannot synthesize a false "succeeded" +// terminal event after cancellation (ErrResultAfterCancel) — send a +// partial_result or an error event with tool.ErrorCategoryCancelled +// instead. +// - output_chunk (and every other event) ordering is preserved because +// Send serializes every call under one mutex rather than writing to +// the underlying gRPC stream directly from more than one goroutine. +type Stream struct { + mu sync.Mutex + grpcStream slashcommandv1.SlashCommandService_InvokeServer + closed bool + exitStatusSent bool +} + +// newStream wraps g for use by a single Invoke call. +func newStream(g slashcommandv1.SlashCommandService_InvokeServer) *Stream { + return &Stream{grpcStream: g} +} + +// Context returns the Invoke call's context — cancelled by the kernel +// closing the gRPC stream (user interrupt, timeout, turn abort). A +// Provider treats this as normal control flow, never as an error +// condition to log. +func (s *Stream) Context() context.Context { + return s.grpcStream.Context() +} + +// Send sends event, enforcing the stream contract documented on Stream. +// Safe for concurrent use; concurrent Send calls serialize rather than +// racing the underlying gRPC stream. +func (s *Stream) Send(event *Event) error { + if event == nil { + return ErrNilEvent + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return ErrStreamClosed + } + + if event.Result != nil { + select { + case <-s.grpcStream.Context().Done(): + return ErrResultAfterCancel + default: + } + } + + if event.ExitStatus != nil { + if s.exitStatusSent { + return ErrDuplicateExitStatus + } + s.exitStatusSent = true + } + + pe, err := toProtoEvent(event) + if err != nil { + return err + } + if err := s.grpcStream.Send(&slashcommandv1.InvokeResponse{Event: pe}); err != nil { + return err + } + + if event.Result != nil || event.Error != nil { + s.closed = true + } + return nil +} + +// closedTerminal reports whether a terminal Result or Error event has +// already been sent — used by server.go to detect a Provider.Invoke that +// returned nil without ever closing the stream. +func (s *Stream) closedTerminal() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.closed +} diff --git a/pkg/slashcommand/stream_test.go b/pkg/slashcommand/stream_test.go new file mode 100644 index 0000000..24c5fe8 --- /dev/null +++ b/pkg/slashcommand/stream_test.go @@ -0,0 +1,190 @@ +package slashcommand + +import ( + "context" + "errors" + "io" + "sync" + "testing" + + "google.golang.org/grpc/metadata" + + slashcommandv1 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" + "github.com/pluggableharness/agent/pkg/tool" +) + +// fakeInvokeServerStream is a hand-written fake of +// slashcommandv1.SlashCommandService_InvokeServer +// (grpc.ServerStreamingServer[InvokeResponse]), per +// .claude/rules/go-testing.md's "fakes, not mocking frameworks" rule. +type fakeInvokeServerStream struct { + ctx context.Context + sendErr error + + mu sync.Mutex + sent []*slashcommandv1.InvokeResponse +} + +func newFakeInvokeServerStream(ctx context.Context) *fakeInvokeServerStream { + return &fakeInvokeServerStream{ctx: ctx} +} + +func (f *fakeInvokeServerStream) Send(r *slashcommandv1.InvokeResponse) error { + if f.sendErr != nil { + return f.sendErr + } + f.mu.Lock() + defer f.mu.Unlock() + f.sent = append(f.sent, r) + return nil +} + +func (f *fakeInvokeServerStream) events() []*slashcommandv1.InvokeResponse { + f.mu.Lock() + defer f.mu.Unlock() + return append([]*slashcommandv1.InvokeResponse(nil), f.sent...) +} + +func (f *fakeInvokeServerStream) SetHeader(metadata.MD) error { return nil } +func (f *fakeInvokeServerStream) SendHeader(metadata.MD) error { return nil } +func (f *fakeInvokeServerStream) SetTrailer(metadata.MD) {} +func (f *fakeInvokeServerStream) Context() context.Context { return f.ctx } +func (f *fakeInvokeServerStream) SendMsg(m any) error { + return f.Send(m.(*slashcommandv1.InvokeResponse)) +} +func (f *fakeInvokeServerStream) RecvMsg(any) error { return io.EOF } + +var _ slashcommandv1.SlashCommandService_InvokeServer = (*fakeInvokeServerStream)(nil) + +func TestStreamSendTerminalClosesStream(t *testing.T) { + t.Parallel() + + f := newFakeInvokeServerStream(t.Context()) + s := newStream(f) + + if err := s.Send(NewOutputChunkEvent(tool.OutputStreamStdout, []byte("a"))); err != nil { + t.Fatalf("Send(output_chunk): %v", err) + } + if s.closedTerminal() { + t.Fatal("closedTerminal() = true before any terminal event") + } + + if err := s.Send(NewResultEvent(map[string]any{"ok": true})); err != nil { + t.Fatalf("Send(result): %v", err) + } + if !s.closedTerminal() { + t.Fatal("closedTerminal() = false after a result event") + } + + // A second terminal event — even a different one — after the first + // MUST be rejected: exactly one of result/error closes the stream. + err := s.Send(NewErrorEvent(&tool.Error{Category: tool.ErrorCategoryUnknown, Message: "too late"})) + if !errors.Is(err, ErrStreamClosed) { + t.Fatalf("second terminal Send() error = %v, want wrapping %v", err, ErrStreamClosed) + } + + if got := len(f.events()); got != 2 { + t.Errorf("events sent = %d, want 2 (the rejected send must not reach the wire)", got) + } +} + +func TestStreamSendNilEvent(t *testing.T) { + t.Parallel() + + s := newStream(newFakeInvokeServerStream(t.Context())) + if err := s.Send(nil); !errors.Is(err, ErrNilEvent) { + t.Errorf("Send(nil) error = %v, want wrapping %v", err, ErrNilEvent) + } +} + +func TestStreamDuplicateExitStatusRejected(t *testing.T) { + t.Parallel() + + s := newStream(newFakeInvokeServerStream(t.Context())) + + if err := s.Send(NewExitStatusEvent(0, nil)); err != nil { + t.Fatalf("first exit_status Send(): %v", err) + } + err := s.Send(NewExitStatusEvent(1, nil)) + if !errors.Is(err, ErrDuplicateExitStatus) { + t.Fatalf("second exit_status Send() error = %v, want wrapping %v", err, ErrDuplicateExitStatus) + } +} + +func TestStreamResultAfterCancelRejected(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + f := newFakeInvokeServerStream(ctx) + s := newStream(f) + + cancel() // simulate the kernel closing the stream mid-call. + + err := s.Send(NewResultEvent(map[string]any{"ok": true})) + if !errors.Is(err, ErrResultAfterCancel) { + t.Fatalf("Send(result) after cancel error = %v, want wrapping %v", err, ErrResultAfterCancel) + } + + // A best-effort partial-mutation report MUST still be sendable after + // cancellation — only a synthesized success result is refused. + if err := s.Send(NewPartialResultEvent(map[string]any{"partial": true})); err != nil { + t.Fatalf("Send(partial_result) after cancel: %v", err) + } + if err := s.Send(NewErrorEvent(&tool.Error{Category: tool.ErrorCategoryCancelled, Message: "cancelled"})); err != nil { + t.Fatalf("Send(error, cancelled) after cancel: %v", err) + } + if !s.closedTerminal() { + t.Error("closedTerminal() = false after a cancelled-category error event") + } +} + +func TestStreamSendPropagatesTransportError(t *testing.T) { + t.Parallel() + + wantErr := errors.New("broken pipe") + f := newFakeInvokeServerStream(t.Context()) + f.sendErr = wantErr + s := newStream(f) + + err := s.Send(NewOutputChunkEvent(tool.OutputStreamStdout, []byte("x"))) + if !errors.Is(err, wantErr) { + t.Fatalf("Send() error = %v, want wrapping %v", err, wantErr) + } + if s.closedTerminal() { + t.Error("closedTerminal() = true after a non-terminal event's Send failed") + } +} + +func TestStreamSendPreservesOrdering(t *testing.T) { + t.Parallel() + + f := newFakeInvokeServerStream(t.Context()) + s := newStream(f) + + var wg sync.WaitGroup + const n = 20 + wg.Add(n) + for i := range n { + go func(i int) { + defer wg.Done() + _ = s.Send(NewProgressEvent("step", nil)) + _ = i + }(i) + } + wg.Wait() + + if got := len(f.events()); got != n { + t.Fatalf("events sent = %d, want %d (concurrent Send calls must not race the transport)", got, n) + } +} + +func TestStreamContext(t *testing.T) { + t.Parallel() + + ctx := t.Context() + f := newFakeInvokeServerStream(ctx) + s := newStream(f) + if s.Context() != ctx { + t.Error("Context() did not return the underlying gRPC stream's context") + } +} diff --git a/pkg/telemetry/doc.go b/pkg/telemetry/doc.go index f1c06eb..89808d8 100644 --- a/pkg/telemetry/doc.go +++ b/pkg/telemetry/doc.go @@ -9,7 +9,7 @@ // plugin author's part. // // Unlike the other pkg/ directories, this package has no -// proto/ subdirectory: telemetry is not one of the six plugin categories +// proto/ subdirectory: telemetry is not one of the seven plugin categories // and has no wire protocol of its own. It is a pure Go convenience // wrapper. package telemetry diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index 134a970..4631f59 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -5,6 +5,8 @@ import ( "fmt" "os" + "go.opentelemetry.io/otel/trace" + "github.com/pluggableharness/agent/internal/telemetry" "github.com/pluggableharness/agent/internal/telemetry/drivers" ) @@ -14,12 +16,49 @@ import ( // unset) is treated as the gRPC transport, matching the SDK's own default. const otlpHTTPProtocol = "http/protobuf" -// Bootstrap constructs a telemetry.Provider for a plugin subprocess, -// reading configuration from the process environment rather than -// requiring the plugin author to hand-build an internal/telemetry.Config — -// there is no kernel-to-plugin config-passing RPC for this today -// (kernel-callbacks.md defines no such primitive), so environment -// variables are the only channel available in v0. +// Provider is the minimal surface Bootstrap hands back to a plugin author — +// a public interface local to this package, not *internal/telemetry.Provider +// itself. internal/ packages are importable only from within this module +// (go-layout.md), so an out-of-tree plugin repo consuming pkg/telemetry via +// the module proxy cannot name *internal/telemetry.Provider in its own code +// at all: returning the concrete internal type made it usable only as an +// opaque value passed straight back into this package's own functions, +// defeating the point of pkg/ as the third-party-consumable surface +// (pkg/kernel/doc.go's "this package deliberately does not import +// internal/" note documents the same boundary; pkg/telemetry is the one +// deliberate, documented exception allowed to import internal/telemetry +// internally, but its exported surface still MUST stay expressible outside +// this module). *internal/telemetry.Provider satisfies this interface +// structurally — Go interfaces are implicit, so no change to +// internal/telemetry was needed for Bootstrap's new return type to compile. +// +// Instruments() and Config() are deliberately not part of this interface: +// both return internal/telemetry-only types (*Instruments, Config), and +// re-exporting either is a metrics-API redesign larger than this fix's +// scope — a plugin author has no path to either through this package +// today. That's a known, tracked gap, not an oversight. +type Provider interface { + // Shutdown flushes and closes the underlying tracer/meter/logger + // providers. Idempotent — safe to call more than once, returning the + // first call's result every time (internal/telemetry.Provider.Shutdown's + // own doc comment explains why that guard exists). + Shutdown(ctx context.Context) error + + // ForceFlush flushes any spans/log records queued in their batch + // processors without waiting for the normal export interval. A no-op + // for a disabled signal. + ForceFlush(ctx context.Context) error + + // Tracer returns this process's tracer for starting spans. + Tracer() trace.Tracer +} + +// Bootstrap constructs a Provider for a plugin subprocess, reading +// configuration from the process environment rather than requiring the +// plugin author to hand-build an internal/telemetry.Config — there is no +// kernel-to-plugin config-passing RPC for this today (kernel-callbacks.md +// defines no such primitive), so environment variables are the only +// channel available in v0. // // serviceName should identify the plugin itself (its manifest name is a // good choice). Call the returned shutdown func before the process exits @@ -34,7 +73,7 @@ const otlpHTTPProtocol = "http/protobuf" // (resource.WithFromEnv, internal/telemetry.BuildResource), which the // kernel stamps into this process's environment at launch // (internal/telemetry.ResourceEnv). -func Bootstrap(ctx context.Context, serviceName string) (*telemetry.Provider, func(context.Context) error, error) { +func Bootstrap(ctx context.Context, serviceName string) (Provider, func(context.Context) error, error) { cfg := telemetry.DefaultConfig cfg.ServiceName = serviceName diff --git a/pkg/telemetry/telemetry_test.go b/pkg/telemetry/telemetry_test.go index 6353556..459cc10 100644 --- a/pkg/telemetry/telemetry_test.go +++ b/pkg/telemetry/telemetry_test.go @@ -28,6 +28,35 @@ func unsetEnvForTest(t *testing.T, key string) { }) } +// TestBootstrap_returnsPublicProviderInterface confirms Bootstrap's return +// type is the package-local Provider interface (not the unexported-to- +// third-parties *internal/telemetry.Provider it wraps), and that all three +// interface methods are callable through it — the defect this test guards +// against is exactly the "returns an internal/ type a third-party plugin +// author cannot even name" bug described in Provider's doc comment. +func TestBootstrap_returnsPublicProviderInterface(t *testing.T) { + // Not parallel: mutates process environment. + unsetEnvForTest(t, "OTEL_EXPORTER_OTLP_ENDPOINT") + + var provider telemetry.Provider + provider, shutdown, err := telemetry.Bootstrap(context.Background(), "test-plugin") + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + t.Cleanup(func() { + if err := shutdown(context.Background()); err != nil { + t.Errorf("shutdown: %v", err) + } + }) + + if tracer := provider.Tracer(); tracer == nil { + t.Error("Tracer() returned nil") + } + if err := provider.ForceFlush(context.Background()); err != nil { + t.Errorf("ForceFlush: %v", err) + } +} + func TestBootstrap_noEndpointUsesNoop(t *testing.T) { // Not parallel: mutates process environment. unsetEnvForTest(t, "OTEL_EXPORTER_OTLP_ENDPOINT") diff --git a/pkg/tool/capabilities.go b/pkg/tool/capabilities.go new file mode 100644 index 0000000..99ea29e --- /dev/null +++ b/pkg/tool/capabilities.go @@ -0,0 +1,51 @@ +package tool + +import ( + "context" + "fmt" + + toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" +) + +// BuildGetSchemaResponse assembles the full GetSchemaResponse for p: every +// operation's Schema (via p.Schema, converted and validated with +// toProtoSchema), plus this provider's config schema, slash commands, +// and supported hook points when p additionally implements +// ConfigSchemaProvider, SlashCommandProvider, or HookPointProvider — see +// tool.go. This package does not re-validate a ConfigSchemaProvider's +// output; it trusts pkg/config's own Attribute/Schema validation, which a +// well-behaved ConfigSchemaProvider implementation is expected to have run +// already. +func BuildGetSchemaResponse(ctx context.Context, p Provider) (*toolv1.GetSchemaResponse, error) { + schemas, err := p.Schema(ctx) + if err != nil { + return nil, fmt.Errorf("tool: get schema: %w", err) + } + + tools := make([]*toolv1.ToolSchema, 0, len(schemas)) + for _, s := range schemas { + ps, err := toProtoSchema(s) + if err != nil { + return nil, fmt.Errorf("tool: get schema: %w", err) + } + tools = append(tools, ps) + } + + resp := &toolv1.GetSchemaResponse{Tools: tools} + + if cs, ok := p.(ConfigSchemaProvider); ok { + schema, err := cs.ConfigSchema() + if err != nil { + return nil, fmt.Errorf("tool: get schema: config schema: %w", err) + } + resp.ConfigSchema = schema + } + if sc, ok := p.(SlashCommandProvider); ok { + resp.SlashCommands = sc.SlashCommands() + } + if hp, ok := p.(HookPointProvider); ok { + resp.SupportedHookPoints = hp.SupportedHookPoints() + } + + return resp, nil +} diff --git a/pkg/tool/capabilities_test.go b/pkg/tool/capabilities_test.go new file mode 100644 index 0000000..666eae9 --- /dev/null +++ b/pkg/tool/capabilities_test.go @@ -0,0 +1,119 @@ +package tool_test + +import ( + "context" + "errors" + "testing" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + "github.com/pluggableharness/agent/pkg/tool" +) + +func TestBuildGetSchemaResponseBasic(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + schemaFunc: func(context.Context) ([]*tool.Schema, error) { + return []*tool.Schema{validSchema("read_file"), validSchema("glob")}, nil + }, + } + + resp, err := tool.BuildGetSchemaResponse(t.Context(), p) + if err != nil { + t.Fatalf("BuildGetSchemaResponse: %v", err) + } + if got := len(resp.GetTools()); got != 2 { + t.Fatalf("len(Tools) = %d, want 2", got) + } + if resp.GetConfigSchema() != nil { + t.Errorf("ConfigSchema = %v, want nil (provider does not implement ConfigSchemaProvider)", resp.GetConfigSchema()) + } + if resp.GetSlashCommands() != nil { + t.Errorf("SlashCommands = %v, want nil", resp.GetSlashCommands()) + } + if resp.GetSupportedHookPoints() != nil { + t.Errorf("SupportedHookPoints = %v, want nil", resp.GetSupportedHookPoints()) + } +} + +func TestBuildGetSchemaResponseSchemaError(t *testing.T) { + t.Parallel() + + wantErr := errors.New("boom") + p := &fakeProvider{ + schemaFunc: func(context.Context) ([]*tool.Schema, error) { return nil, wantErr }, + } + + _, err := tool.BuildGetSchemaResponse(t.Context(), p) + if !errors.Is(err, wantErr) { + t.Fatalf("BuildGetSchemaResponse() error = %v, want wrapping %v", err, wantErr) + } +} + +func TestBuildGetSchemaResponseInvalidSchema(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + schemaFunc: func(context.Context) ([]*tool.Schema, error) { + return []*tool.Schema{{Name: ""}}, nil // missing everything + }, + } + + _, err := tool.BuildGetSchemaResponse(t.Context(), p) + if err == nil { + t.Fatal("BuildGetSchemaResponse() with an invalid Schema: want error, got nil") + } +} + +func TestBuildGetSchemaResponseOptionalCapabilities(t *testing.T) { + t.Parallel() + + base := &fakeProvider{ + schemaFunc: func(context.Context) ([]*tool.Schema, error) { + return []*tool.Schema{validSchema("op")}, nil + }, + } + wantSchema := &configv1.ConfigSchema{} + wantSlash := []*commonv1.PromptExpansionSpec{{Name: "foo", Template: "do foo"}} + wantHooks := []commonv1.HookPoint{commonv1.HookPoint_HOOK_POINT_PRE_TOOL_CALL} + + p := &fakeFullProvider{ + fakeProvider: base, + configSchemaFunc: func() (*configv1.ConfigSchema, error) { return wantSchema, nil }, + slashCommands: wantSlash, + hookPoints: wantHooks, + } + + resp, err := tool.BuildGetSchemaResponse(t.Context(), p) + if err != nil { + t.Fatalf("BuildGetSchemaResponse: %v", err) + } + if resp.GetConfigSchema() != wantSchema { + t.Errorf("ConfigSchema = %v, want %v", resp.GetConfigSchema(), wantSchema) + } + if len(resp.GetSlashCommands()) != 1 || resp.GetSlashCommands()[0].GetName() != "foo" { + t.Errorf("SlashCommands = %v", resp.GetSlashCommands()) + } + if len(resp.GetSupportedHookPoints()) != 1 || resp.GetSupportedHookPoints()[0] != commonv1.HookPoint_HOOK_POINT_PRE_TOOL_CALL { + t.Errorf("SupportedHookPoints = %v", resp.GetSupportedHookPoints()) + } +} + +func TestBuildGetSchemaResponseConfigSchemaError(t *testing.T) { + t.Parallel() + + wantErr := errors.New("bad config schema") + base := &fakeProvider{ + schemaFunc: func(context.Context) ([]*tool.Schema, error) { return nil, nil }, + } + p := &fakeFullProvider{ + fakeProvider: base, + configSchemaFunc: func() (*configv1.ConfigSchema, error) { return nil, wantErr }, + } + + _, err := tool.BuildGetSchemaResponse(t.Context(), p) + if !errors.Is(err, wantErr) { + t.Fatalf("BuildGetSchemaResponse() error = %v, want wrapping %v", err, wantErr) + } +} diff --git a/pkg/tool/convert.go b/pkg/tool/convert.go new file mode 100644 index 0000000..01d378c --- /dev/null +++ b/pkg/tool/convert.go @@ -0,0 +1,341 @@ +package tool + +import ( + "errors" + "fmt" + + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/structpb" + + toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" +) + +// Sentinel errors returned by the domain<->proto conversions in this file. +var ( + // ErrNilSchema is returned when converting a nil *Schema. + ErrNilSchema = errors.New("tool: tool schema must not be nil") + // ErrNilResult is returned when converting a nil *Result. + ErrNilResult = errors.New("tool: tool result must not be nil") + // ErrNilError is returned when converting a nil *Error. + ErrNilError = errors.New("tool: tool error must not be nil") + // ErrNilCall is returned when converting a nil *toolv1.ToolCall. + ErrNilCall = errors.New("tool: call must not be nil") + // ErrNilEvent is returned by Stream.Send and toProtoEvent for a + // nil *Event. + ErrNilEvent = errors.New("tool: event must not be nil") + // ErrEventFieldCount is returned when a Event does not have + // exactly one of its six fields set. + ErrEventFieldCount = errors.New("tool: event must set exactly one field") + + // ErrEmptyName is returned when a Schema's Name is empty. + ErrEmptyName = errors.New("tool: name must not be empty") + // ErrUnspecifiedKind is returned when a Schema's Kind is + // KindUnspecified. + ErrUnspecifiedKind = errors.New("tool: kind must not be unspecified") + // ErrEmptyDescription is returned when a Schema's Description is + // empty. + ErrEmptyDescription = errors.New("tool: description must not be empty") + // ErrNilInputSchema is returned when a Schema's InputSchema is + // nil. + ErrNilInputSchema = errors.New("tool: input_schema must not be nil") + // ErrNilOutputSchema is returned when a Schema's OutputSchema is + // nil. + ErrNilOutputSchema = errors.New("tool: output_schema must not be nil") + // ErrInvalidRiskForKind is returned when a Schema's Risk does not + // match what its Kind requires — RiskClassReadOnly for + // KindDataSource/KindInteractive, one of + // low/moderate/high/critical for KindResource. + ErrInvalidRiskForKind = errors.New("tool: risk does not match kind's required risk classification") + // ErrConcurrencyRequired is returned when a Schema's Concurrency + // is nil for a kind other than KindInteractive. + ErrConcurrencyRequired = errors.New("tool: concurrency must be set except for kind interactive") + // ErrConcurrencyForbiddenForInteractive is returned when a + // KindInteractive Schema declares a non-nil Concurrency. + // docs/specifications/tool/data-types.md#concurrencyspec says the + // kernel MUST ignore a declared ConcurrencySpec for an interactive + // operation and enforce sequential execution unconditionally; this + // package's judgment call is to reject the construction outright + // instead of silently stripping it, surfacing the author's mistake + // immediately rather than papering over it — see doc.go and the + // package report for this call's rationale. + ErrConcurrencyForbiddenForInteractive = errors.New("tool: concurrency must not be declared for kind interactive") +) + +// toProtoKind converts k to its wire representation. +func toProtoKind(k Kind) toolv1.ToolKind { + switch k { + case KindResource: + return toolv1.ToolKind_TOOL_KIND_RESOURCE + case KindDataSource: + return toolv1.ToolKind_TOOL_KIND_DATA_SOURCE + case KindInteractive: + return toolv1.ToolKind_TOOL_KIND_INTERACTIVE + default: + return toolv1.ToolKind_TOOL_KIND_UNSPECIFIED + } +} + +// toProtoRiskClass converts r to its wire representation. +func toProtoRiskClass(r RiskClass) toolv1.RiskClass { + switch r { + case RiskClassReadOnly: + return toolv1.RiskClass_RISK_CLASS_READ_ONLY + case RiskClassLow: + return toolv1.RiskClass_RISK_CLASS_LOW + case RiskClassModerate: + return toolv1.RiskClass_RISK_CLASS_MODERATE + case RiskClassHigh: + return toolv1.RiskClass_RISK_CLASS_HIGH + case RiskClassCritical: + return toolv1.RiskClass_RISK_CLASS_CRITICAL + default: + return toolv1.RiskClass_RISK_CLASS_UNSPECIFIED + } +} + +// toProtoOutputStream converts s to its wire representation. +func toProtoOutputStream(s OutputStream) toolv1.OutputStream { + switch s { + case OutputStreamStdout: + return toolv1.OutputStream_OUTPUT_STREAM_STDOUT + case OutputStreamStderr: + return toolv1.OutputStream_OUTPUT_STREAM_STDERR + default: + return toolv1.OutputStream_OUTPUT_STREAM_UNSPECIFIED + } +} + +// toProtoErrorCategory converts c to its wire representation. +func toProtoErrorCategory(c ErrorCategory) toolv1.ToolErrorCategory { + switch c { + case ErrorCategoryInvalidArguments: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_INVALID_ARGUMENTS + case ErrorCategoryNotFound: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_NOT_FOUND + case ErrorCategoryPermissionDenied: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_PERMISSION_DENIED + case ErrorCategoryExecutionFailed: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_EXECUTION_FAILED + case ErrorCategoryTimeout: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_TIMEOUT + case ErrorCategoryConcurrencyConflict: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_CONCURRENCY_CONFLICT + case ErrorCategoryCancelled: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_CANCELLED + case toolErrorCategoryProcessCrashed: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_PROCESS_CRASHED + case ErrorCategoryUnknown: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_UNKNOWN + default: + return toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_UNSPECIFIED + } +} + +// toProtoConcurrencySpec converts c to its wire representation. A nil c +// converts to nil. +func toProtoConcurrencySpec(c *ConcurrencySpec) *toolv1.ConcurrencySpec { + if c == nil { + return nil + } + return &toolv1.ConcurrencySpec{Safe: c.Safe, KeyFields: c.KeyFields} +} + +// validateSchema checks the MUST-level invariants +// docs/specifications/tool/protocol.md#getschema and +// docs/specifications/tool/data-types.md#riskclass place on a Schema. +func validateSchema(s *Schema) error { + if s.Name == "" { + return ErrEmptyName + } + if s.Kind == KindUnspecified { + return ErrUnspecifiedKind + } + if s.Description == "" { + return ErrEmptyDescription + } + if s.InputSchema == nil { + return ErrNilInputSchema + } + if s.OutputSchema == nil { + return ErrNilOutputSchema + } + + switch s.Kind { + case KindDataSource, KindInteractive: + if s.Risk != RiskClassReadOnly { + return fmt.Errorf("%w: %s requires read_only, got %s", ErrInvalidRiskForKind, s.Kind, s.Risk) + } + case KindResource: + switch s.Risk { + case RiskClassLow, RiskClassModerate, RiskClassHigh, RiskClassCritical: + default: + return fmt.Errorf("%w: resource requires one of low/moderate/high/critical, got %s", ErrInvalidRiskForKind, s.Risk) + } + } + + if s.Kind == KindInteractive { + if s.Concurrency != nil { + return ErrConcurrencyForbiddenForInteractive + } + } else if s.Concurrency == nil { + return ErrConcurrencyRequired + } + + return nil +} + +// toProtoSchema validates s and converts it to its wire +// representation. +func toProtoSchema(s *Schema) (*toolv1.ToolSchema, error) { + if s == nil { + return nil, ErrNilSchema + } + if err := validateSchema(s); err != nil { + return nil, fmt.Errorf("tool: tool schema %q: %w", s.Name, err) + } + + ps := &toolv1.ToolSchema{ + Name: s.Name, + Kind: toProtoKind(s.Kind), + Risk: toProtoRiskClass(s.Risk), + Description: s.Description, + InputSchema: s.InputSchema, + OutputSchema: s.OutputSchema, + Streaming: s.Streaming, + Concurrency: toProtoConcurrencySpec(s.Concurrency), + Idempotent: s.Idempotent, + } + if s.DefaultTimeout > 0 { + ps.DefaultTimeout = durationpb.New(s.DefaultTimeout) + } + return ps, nil +} + +// toProtoResult converts r to its wire representation. +func toProtoResult(r *Result) (*toolv1.ToolResult, error) { + if r == nil { + return nil, ErrNilResult + } + payload, err := mapToStruct(r.Payload) + if err != nil { + return nil, fmt.Errorf("tool: tool result: %w", err) + } + return &toolv1.ToolResult{Payload: payload}, nil +} + +// toProtoError validates e's category and converts it to its wire +// representation. +func toProtoError(e *Error) (*toolv1.ToolError, error) { + if e == nil { + return nil, ErrNilError + } + if err := validateErrorCategory(e.Category); err != nil { + return nil, fmt.Errorf("tool: tool error: %w", err) + } + + pe := &toolv1.ToolError{ + Category: toProtoErrorCategory(e.Category), + Message: e.Message, + Retryable: e.Retryable, + } + if len(e.Details) > 0 { + details, err := mapToStruct(e.Details) + if err != nil { + return nil, fmt.Errorf("tool: tool error: details: %w", err) + } + pe.Details = details + } + return pe, nil +} + +// toProtoEvent converts e to its wire representation, rejecting a nil +// event or one that does not set exactly one field — the same "exactly +// one of result/error closes the stream, everything else is optional but +// still exactly-one-of-six-per-message" shape +// docs/specifications/tool/data-types.md#toolcall--toolevent--toolresult +// describes for the underlying oneof. +func toProtoEvent(e *Event) (*toolv1.ToolEvent, error) { + if e == nil { + return nil, ErrNilEvent + } + + set := 0 + for _, isSet := range []bool{e.OutputChunk != nil, e.Progress != nil, e.PartialResult != nil, e.ExitStatus != nil, e.Result != nil, e.Error != nil} { + if isSet { + set++ + } + } + if set != 1 { + return nil, fmt.Errorf("tool: tool event: %w: got %d fields set", ErrEventFieldCount, set) + } + + switch { + case e.OutputChunk != nil: + return &toolv1.ToolEvent{Event: &toolv1.ToolEvent_OutputChunk_{OutputChunk: &toolv1.ToolEvent_OutputChunk{ + Stream: toProtoOutputStream(e.OutputChunk.Stream), + Data: e.OutputChunk.Data, + }}}, nil + case e.Progress != nil: + return &toolv1.ToolEvent{Event: &toolv1.ToolEvent_Progress_{Progress: &toolv1.ToolEvent_Progress{ + Message: e.Progress.Message, + FractionComplete: e.Progress.FractionComplete, + }}}, nil + case e.PartialResult != nil: + payload, err := mapToStruct(e.PartialResult.Payload) + if err != nil { + return nil, fmt.Errorf("tool: tool event: partial_result: %w", err) + } + return &toolv1.ToolEvent{Event: &toolv1.ToolEvent_PartialResult_{PartialResult: &toolv1.ToolEvent_PartialResult{Payload: payload}}}, nil + case e.ExitStatus != nil: + return &toolv1.ToolEvent{Event: &toolv1.ToolEvent_ExitStatus_{ExitStatus: &toolv1.ToolEvent_ExitStatus{ + ExitCode: e.ExitStatus.ExitCode, + Signal: e.ExitStatus.Signal, + }}}, nil + case e.Result != nil: + pr, err := toProtoResult(e.Result) + if err != nil { + return nil, fmt.Errorf("tool: tool event: %w", err) + } + return &toolv1.ToolEvent{Event: &toolv1.ToolEvent_Result{Result: pr}}, nil + default: // e.Error != nil, guaranteed by the exactly-one-field check above. + pe, err := toProtoError(e.Error) + if err != nil { + return nil, fmt.Errorf("tool: tool event: %w", err) + } + return &toolv1.ToolEvent{Event: &toolv1.ToolEvent_Error{Error: pe}}, nil + } +} + +// fromProtoCall converts c from its wire representation. +func fromProtoCall(c *toolv1.ToolCall) (*Call, error) { + if c == nil { + return nil, ErrNilCall + } + return &Call{ + ID: c.GetId(), + ToolName: c.GetToolName(), + Arguments: structToMap(c.GetArguments()), + CallContext: c.GetCallContext(), + }, nil +} + +// structToMap converts s to a plain map, or nil if s is nil. +// structpb.Struct.AsMap never errors. +func structToMap(s *structpb.Struct) map[string]any { + if s == nil { + return nil + } + return s.AsMap() +} + +// mapToStruct converts m to a *structpb.Struct, or nil if m is empty. +func mapToStruct(m map[string]any) (*structpb.Struct, error) { + if len(m) == 0 { + return nil, nil //nolint:nilnil // absence of a payload is a meaningful, documented zero value on the wire (an unset embedded message field), not an ambiguous "no result, no error". + } + s, err := structpb.NewStruct(m) + if err != nil { + return nil, fmt.Errorf("tool: encode struct: %w", err) + } + return s, nil +} diff --git a/pkg/tool/convert_test.go b/pkg/tool/convert_test.go new file mode 100644 index 0000000..ad55adf --- /dev/null +++ b/pkg/tool/convert_test.go @@ -0,0 +1,365 @@ +package tool + +import ( + "errors" + "testing" + + schemav1 "github.com/pluggableharness/agent/pkg/schema/proto/v1" + toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" +) + +func validSchema() *Schema { + return &Schema{ + Name: "read_file", + Kind: KindDataSource, + Risk: RiskClassReadOnly, + Description: "reads a file", + InputSchema: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_OBJECT}, + OutputSchema: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_OBJECT}, + Streaming: false, + Concurrency: &ConcurrencySpec{Safe: true}, + Idempotent: true, + } +} + +func TestToProtoSchemaValid(t *testing.T) { + t.Parallel() + + s := validSchema() + ps, err := toProtoSchema(s) + if err != nil { + t.Fatalf("toProtoSchema: unexpected error: %v", err) + } + if ps.GetName() != "read_file" { + t.Errorf("Name = %q, want %q", ps.GetName(), "read_file") + } + if ps.GetKind() != toolv1.ToolKind_TOOL_KIND_DATA_SOURCE { + t.Errorf("Kind = %v, want %v", ps.GetKind(), toolv1.ToolKind_TOOL_KIND_DATA_SOURCE) + } + if ps.GetRisk() != toolv1.RiskClass_RISK_CLASS_READ_ONLY { + t.Errorf("Risk = %v, want %v", ps.GetRisk(), toolv1.RiskClass_RISK_CLASS_READ_ONLY) + } + if !ps.GetConcurrency().GetSafe() { + t.Errorf("Concurrency.Safe = false, want true") + } +} + +func TestToProtoSchemaInvariants(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*Schema) + wantErr error + }{ + {"nil schema", nil, ErrNilSchema}, + {"empty name", func(s *Schema) { s.Name = "" }, ErrEmptyName}, + {"unspecified kind", func(s *Schema) { s.Kind = KindUnspecified }, ErrUnspecifiedKind}, + {"empty description", func(s *Schema) { s.Description = "" }, ErrEmptyDescription}, + {"nil input schema", func(s *Schema) { s.InputSchema = nil }, ErrNilInputSchema}, + {"nil output schema", func(s *Schema) { s.OutputSchema = nil }, ErrNilOutputSchema}, + {"data_source with non-read_only risk", func(s *Schema) { s.Risk = RiskClassLow }, ErrInvalidRiskForKind}, + {"interactive with non-read_only risk", func(s *Schema) { + s.Kind = KindInteractive + s.Risk = RiskClassLow + s.Concurrency = nil + }, ErrInvalidRiskForKind}, + {"resource with read_only risk", func(s *Schema) { + s.Kind = KindResource + s.Risk = RiskClassReadOnly + }, ErrInvalidRiskForKind}, + {"resource with unspecified risk", func(s *Schema) { + s.Kind = KindResource + s.Risk = RiskClassUnspecified + }, ErrInvalidRiskForKind}, + {"interactive with concurrency declared", func(s *Schema) { + s.Kind = KindInteractive + s.Risk = RiskClassReadOnly + s.Concurrency = &ConcurrencySpec{Safe: true} + }, ErrConcurrencyForbiddenForInteractive}, + {"resource missing concurrency", func(s *Schema) { + s.Kind = KindResource + s.Risk = RiskClassLow + s.Concurrency = nil + }, ErrConcurrencyRequired}, + {"data_source missing concurrency", func(s *Schema) { + s.Concurrency = nil + }, ErrConcurrencyRequired}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var s *Schema + if tt.mutate != nil { + s = validSchema() + tt.mutate(s) + } + + _, err := toProtoSchema(s) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("toProtoSchema() error = %v, want wrapping %v", err, tt.wantErr) + } + }) + } +} + +func TestToProtoSchemaDefaultTimeout(t *testing.T) { + t.Parallel() + + s := validSchema() + s.DefaultTimeout = 30_000_000_000 // 30s, expressed in ns to avoid importing time here. + ps, err := toProtoSchema(s) + if err != nil { + t.Fatalf("toProtoSchema: %v", err) + } + if ps.GetDefaultTimeout() == nil { + t.Fatal("DefaultTimeout not set on proto schema") + } + if got := ps.GetDefaultTimeout().AsDuration().Seconds(); got != 30 { + t.Errorf("DefaultTimeout = %vs, want 30s", got) + } + + s2 := validSchema() + ps2, err := toProtoSchema(s2) + if err != nil { + t.Fatalf("toProtoSchema: %v", err) + } + if ps2.GetDefaultTimeout() != nil { + t.Errorf("DefaultTimeout = %v, want nil (unset)", ps2.GetDefaultTimeout()) + } +} + +func TestToProtoResult(t *testing.T) { + t.Parallel() + + t.Run("nil", func(t *testing.T) { + t.Parallel() + if _, err := toProtoResult(nil); !errors.Is(err, ErrNilResult) { + t.Errorf("error = %v, want wrapping %v", err, ErrNilResult) + } + }) + + t.Run("with payload", func(t *testing.T) { + t.Parallel() + pr, err := toProtoResult(&Result{Payload: map[string]any{"ok": true}}) + if err != nil { + t.Fatalf("toProtoResult: %v", err) + } + if pr.GetPayload().AsMap()["ok"] != true { + t.Errorf("Payload = %v, want ok=true", pr.GetPayload().AsMap()) + } + }) + + t.Run("empty payload", func(t *testing.T) { + t.Parallel() + pr, err := toProtoResult(&Result{}) + if err != nil { + t.Fatalf("toProtoResult: %v", err) + } + if pr.GetPayload() != nil { + t.Errorf("Payload = %v, want nil", pr.GetPayload()) + } + }) +} + +func TestToProtoError(t *testing.T) { + t.Parallel() + + t.Run("nil", func(t *testing.T) { + t.Parallel() + if _, err := toProtoError(nil); !errors.Is(err, ErrNilError) { + t.Errorf("error = %v, want wrapping %v", err, ErrNilError) + } + }) + + t.Run("process_crashed rejected", func(t *testing.T) { + t.Parallel() + e := &Error{Category: toolErrorCategoryProcessCrashed, Message: "died"} + if _, err := toProtoError(e); !errors.Is(err, ErrProcessCrashedCategory) { + t.Errorf("error = %v, want wrapping %v", err, ErrProcessCrashedCategory) + } + }) + + t.Run("valid with details", func(t *testing.T) { + t.Parallel() + e := &Error{Category: ErrorCategoryUnknown, Message: "boom", Retryable: false, Details: map[string]any{"raw": "panic: x"}} + pe, err := toProtoError(e) + if err != nil { + t.Fatalf("toProtoError: %v", err) + } + if pe.GetCategory() != toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_UNKNOWN { + t.Errorf("Category = %v, want UNKNOWN", pe.GetCategory()) + } + if pe.GetDetails().AsMap()["raw"] != "panic: x" { + t.Errorf("Details = %v", pe.GetDetails().AsMap()) + } + }) +} + +func TestToProtoEvent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + event *Event + wantErr error + }{ + {"nil event", nil, ErrNilEvent}, + {"no fields set", &Event{}, ErrEventFieldCount}, + {"two fields set", &Event{OutputChunk: &OutputChunkEvent{}, Progress: &ProgressEvent{}}, ErrEventFieldCount}, + {"output_chunk", NewOutputChunkEvent(OutputStreamStdout, []byte("hi")), nil}, + {"progress", NewProgressEvent("working", nil), nil}, + {"partial_result", NewPartialResultEvent(map[string]any{"n": 1.0}), nil}, + {"exit_status", NewExitStatusEvent(0, nil), nil}, + {"result", NewResultEvent(map[string]any{"ok": true}), nil}, + {"error", NewErrorEvent(&Error{Category: ErrorCategoryTimeout, Message: "slow"}), nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := toProtoEvent(tt.event) + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("toProtoEvent() error = %v, want wrapping %v", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("toProtoEvent() unexpected error: %v", err) + } + }) + } +} + +func TestFromProtoCall(t *testing.T) { + t.Parallel() + + t.Run("nil", func(t *testing.T) { + t.Parallel() + if _, err := fromProtoCall(nil); !errors.Is(err, ErrNilCall) { + t.Errorf("error = %v, want wrapping %v", err, ErrNilCall) + } + }) + + t.Run("valid", func(t *testing.T) { + t.Parallel() + args, err := mapToStruct(map[string]any{"path": "a.go"}) + if err != nil { + t.Fatalf("mapToStruct: %v", err) + } + pc := &toolv1.ToolCall{Id: "call-1", ToolName: "read_file", Arguments: args} + c, err := fromProtoCall(pc) + if err != nil { + t.Fatalf("fromProtoCall: %v", err) + } + if c.ID != "call-1" || c.ToolName != "read_file" { + t.Errorf("got %+v", c) + } + if c.Arguments["path"] != "a.go" { + t.Errorf("Arguments = %v", c.Arguments) + } + }) +} + +func TestToProtoEnumConverters(t *testing.T) { + t.Parallel() + + kindTests := []struct { + in Kind + want toolv1.ToolKind + }{ + {KindResource, toolv1.ToolKind_TOOL_KIND_RESOURCE}, + {KindDataSource, toolv1.ToolKind_TOOL_KIND_DATA_SOURCE}, + {KindInteractive, toolv1.ToolKind_TOOL_KIND_INTERACTIVE}, + {KindUnspecified, toolv1.ToolKind_TOOL_KIND_UNSPECIFIED}, + {Kind(99), toolv1.ToolKind_TOOL_KIND_UNSPECIFIED}, + } + for _, tt := range kindTests { + if got := toProtoKind(tt.in); got != tt.want { + t.Errorf("toProtoKind(%v) = %v, want %v", tt.in, got, tt.want) + } + } + + riskTests := []struct { + in RiskClass + want toolv1.RiskClass + }{ + {RiskClassReadOnly, toolv1.RiskClass_RISK_CLASS_READ_ONLY}, + {RiskClassLow, toolv1.RiskClass_RISK_CLASS_LOW}, + {RiskClassModerate, toolv1.RiskClass_RISK_CLASS_MODERATE}, + {RiskClassHigh, toolv1.RiskClass_RISK_CLASS_HIGH}, + {RiskClassCritical, toolv1.RiskClass_RISK_CLASS_CRITICAL}, + {RiskClassUnspecified, toolv1.RiskClass_RISK_CLASS_UNSPECIFIED}, + {RiskClass(99), toolv1.RiskClass_RISK_CLASS_UNSPECIFIED}, + } + for _, tt := range riskTests { + if got := toProtoRiskClass(tt.in); got != tt.want { + t.Errorf("toProtoRiskClass(%v) = %v, want %v", tt.in, got, tt.want) + } + } + + streamTests := []struct { + in OutputStream + want toolv1.OutputStream + }{ + {OutputStreamStdout, toolv1.OutputStream_OUTPUT_STREAM_STDOUT}, + {OutputStreamStderr, toolv1.OutputStream_OUTPUT_STREAM_STDERR}, + {OutputStreamUnspecified, toolv1.OutputStream_OUTPUT_STREAM_UNSPECIFIED}, + {OutputStream(99), toolv1.OutputStream_OUTPUT_STREAM_UNSPECIFIED}, + } + for _, tt := range streamTests { + if got := toProtoOutputStream(tt.in); got != tt.want { + t.Errorf("toProtoOutputStream(%v) = %v, want %v", tt.in, got, tt.want) + } + } + + categoryTests := []struct { + in ErrorCategory + want toolv1.ToolErrorCategory + }{ + {ErrorCategoryInvalidArguments, toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_INVALID_ARGUMENTS}, + {ErrorCategoryNotFound, toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_NOT_FOUND}, + {ErrorCategoryPermissionDenied, toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_PERMISSION_DENIED}, + {ErrorCategoryExecutionFailed, toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_EXECUTION_FAILED}, + {ErrorCategoryTimeout, toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_TIMEOUT}, + {ErrorCategoryConcurrencyConflict, toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_CONCURRENCY_CONFLICT}, + {ErrorCategoryCancelled, toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_CANCELLED}, + {toolErrorCategoryProcessCrashed, toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_PROCESS_CRASHED}, + {ErrorCategoryUnknown, toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_UNKNOWN}, + {ErrorCategoryUnspecified, toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_UNSPECIFIED}, + {ErrorCategory(99), toolv1.ToolErrorCategory_TOOL_ERROR_CATEGORY_UNSPECIFIED}, + } + for _, tt := range categoryTests { + if got := toProtoErrorCategory(tt.in); got != tt.want { + t.Errorf("toProtoErrorCategory(%v) = %v, want %v", tt.in, got, tt.want) + } + } + + if got := toProtoConcurrencySpec(nil); got != nil { + t.Errorf("toProtoConcurrencySpec(nil) = %v, want nil", got) + } +} + +func TestStructRoundTrip(t *testing.T) { + t.Parallel() + + if got := structToMap(nil); got != nil { + t.Errorf("structToMap(nil) = %v, want nil", got) + } + + s, err := mapToStruct(nil) + if err != nil || s != nil { + t.Errorf("mapToStruct(nil) = (%v, %v), want (nil, nil)", s, err) + } + + m := map[string]any{"a": 1.0, "b": "x"} + ps, err := mapToStruct(m) + if err != nil { + t.Fatalf("mapToStruct: %v", err) + } + got := structToMap(ps) + if got["a"] != 1.0 || got["b"] != "x" { + t.Errorf("round trip = %v, want %v", got, m) + } +} diff --git a/pkg/tool/doc.go b/pkg/tool/doc.go new file mode 100644 index 0000000..dd99a79 --- /dev/null +++ b/pkg/tool/doc.go @@ -0,0 +1,32 @@ +// Package tool implements the hand-written, plugin-author-facing Go SDK +// for the tool provider category — file I/O, shell execution, search, web +// access, task tracking, and sub-agent spawning +// (docs/specifications/tool/README.md). It sits directly on top of the +// generated pkg/tool/proto/v1 stubs (toolv1) and the shared foundation +// packages (pkg/plugin, pkg/config, pkg/schema, pkg/render): a plugin +// author implements Provider, builds a *Service with NewService, and +// passes it to plugin.Config.Services before calling plugin.Serve. +// +// See docs/specifications/tool/protocol.md for the six RPCs this package +// wires up (GetSchema, Configure, Invoke, Render, Preview, Describe — the +// first three plus Describe MUST be implemented by every provider; Render +// and Preview MAY), docs/specifications/tool/data-types.md for the +// Schema / Call / Event / Result / ConcurrencySpec shapes, +// and docs/specifications/tool/conformance.md for the ErrorCategory +// taxonomy and the full MUST/SHOULD/MAY summary matrix this package +// enforces where it can. +// +// # Types shared verbatim with pkg/slashcommand +// +// docs/specifications/slashcommand/data-types.md mandates that the +// slashcommand category reuse six of this package's types VERBATIM, with +// no parallel redeclaration: Kind, RiskClass, ConcurrencySpec, +// Result, Error, and OutputStream. All six are declared at this +// package's top level specifically so pkg/slashcommand can import and use +// them directly, and none of the six embeds anything Call-specific (no +// tool_name, no call ID, nothing that wouldn't make equal sense reused for +// a slash command's own direct-invoke result). A future reader MUST NOT +// rename, reshape, or fork these six types into pkg/slashcommand or any +// other package — extend this package instead if their shape ever needs +// to change, and update every consumer in lockstep. +package tool diff --git a/pkg/tool/errors.go b/pkg/tool/errors.go new file mode 100644 index 0000000..f18ed0a --- /dev/null +++ b/pkg/tool/errors.go @@ -0,0 +1,263 @@ +package tool + +import ( + "errors" + "fmt" + "strings" + + "google.golang.org/grpc/codes" + + "github.com/pluggableharness/agent/pkg/plugin" +) + +// errorDomain is the domain string passed to plugin.StatusError for every +// tool-category RPC error crossing the plugin boundary, per +// .claude/rules/grpc.md's "most specific code ... category enum in +// structured detail" rule. +const errorDomain = "tool.pluggableharness.dev" + +// ErrorCategory classifies why an Invoke call failed, per +// docs/specifications/tool/conformance.md#error-taxonomy. Deliberately +// distinct from the model category's own error taxonomy — there is no +// rate_limited or context_length_exceeded here, those are model-vendor +// concepts. One of the six types pkg/slashcommand reuses verbatim; see +// doc.go. +type ErrorCategory int + +const ( + // ErrorCategoryUnspecified is the zero value. Never valid for a + // real error. + ErrorCategoryUnspecified ErrorCategory = iota + // ErrorCategoryInvalidArguments means input failed input_schema + // validation. + ErrorCategoryInvalidArguments + // ErrorCategoryNotFound means the target of the operation + // doesn't exist (path, URL, symbol, ...). + ErrorCategoryNotFound + // ErrorCategoryPermissionDenied means OS/policy denied the + // underlying operation. + ErrorCategoryPermissionDenied + // ErrorCategoryExecutionFailed means the operation ran but + // failed on its own terms (non-zero exit, compiler error, HTTP + // 4xx/5xx) — not a plugin bug. + ErrorCategoryExecutionFailed + // ErrorCategoryTimeout means the operation exceeded a plugin- or + // kernel-enforced deadline. + ErrorCategoryTimeout + // ErrorCategoryConcurrencyConflict means the provider detected a + // conflicting concurrent call it could not serialize itself — + // signals the kernel to retry serialized. + ErrorCategoryConcurrencyConflict + // ErrorCategoryCancelled means the stream was cancelled — not + // "an error" in the failure sense; kept distinct so the kernel + // doesn't surface it to the model as a tool failure when the whole + // turn is being aborted anyway. + ErrorCategoryCancelled + // toolErrorCategoryProcessCrashed is unexported and has no + // constructor: a plugin subprocess that crashes mid-Invoke obviously + // cannot emit this category about itself. It exists here only so + // GRPCCode and this type's String method can render/map a category + // value the kernel synthesizes on the transport side after a crash — + // see NewError's doc comment for how this package makes it + // unconstructable by a plugin author. + toolErrorCategoryProcessCrashed + // ErrorCategoryUnknown means anything else. Details MUST include + // the raw underlying error. + ErrorCategoryUnknown +) + +// String returns c's wire-name-derived lowercase form, e.g. +// "invalid_arguments". +func (c ErrorCategory) String() string { + switch c { + case ErrorCategoryUnspecified: + return "unspecified" + case ErrorCategoryInvalidArguments: + return "invalid_arguments" + case ErrorCategoryNotFound: + return "not_found" + case ErrorCategoryPermissionDenied: + return "permission_denied" + case ErrorCategoryExecutionFailed: + return "execution_failed" + case ErrorCategoryTimeout: + return "timeout" + case ErrorCategoryConcurrencyConflict: + return "concurrency_conflict" + case ErrorCategoryCancelled: + return "cancelled" + case toolErrorCategoryProcessCrashed: + return "process_crashed" + case ErrorCategoryUnknown: + return "unknown" + default: + return "unrecognized" + } +} + +// Error is the terminal, failed outcome of an Invoke call, per +// docs/specifications/tool/conformance.md#error-taxonomy. Implements the +// standard error interface via Error. One of the six types +// pkg/slashcommand reuses verbatim; see doc.go. Deliberately holds nothing +// Call-specific so it reuses cleanly for a slash command's own +// direct-invoke failure. +type Error struct { + // Category MUST be set — see ErrorCategory. Never + // ErrorCategoryUnspecified and never the kernel-only + // process_crashed category; see NewError. + Category ErrorCategory + // Message is human-readable. MUST be set. + Message string + // Retryable MUST be set. + Retryable bool + // Details is provider-specific structured detail. MUST include the + // raw underlying error for ErrorCategoryUnknown. + Details map[string]any +} + +// Error implements the standard error interface, returning Message. +func (e *Error) Error() string { + if e == nil { + return "" + } + return e.Message +} + +// Sentinel errors returned by NewError, checked with errors.Is. +var ( + // ErrEmptyMessage is returned when message is empty. + ErrEmptyMessage = errors.New("tool: message must not be empty") + // ErrUnspecifiedCategory is returned when category is + // ErrorCategoryUnspecified or any value outside the declared + // enum. + ErrUnspecifiedCategory = errors.New("tool: category must not be unspecified") + // ErrProcessCrashedCategory is returned when category is the + // kernel-only process_crashed category. A plugin process that + // crashes mid-Invoke cannot emit an event about its own crash — the + // kernel synthesizes this category from the transport failure + // instead — so NewError refuses to construct one regardless of + // how the caller obtained the category value, closing the gap a bare + // unexported constant alone would leave open (ErrorCategory is + // just an int; a caller could still write ErrorCategory(8)). + ErrProcessCrashedCategory = errors.New("tool: process_crashed is kernel-synthesized only and cannot be constructed by a plugin") +) + +// NewError builds and validates a Error. It rejects an empty +// message, ErrorCategoryUnspecified, and the kernel-only +// process_crashed category (see ErrProcessCrashedCategory) — this is the +// package's chosen mechanism for making process_crashed unconstructable by +// a plugin author, per +// docs/specifications/tool/conformance.md#error-taxonomy: "a plugin +// process that crashes obviously cannot emit this itself." +func NewError(category ErrorCategory, message string, retryable bool, details map[string]any) (*Error, error) { + if message == "" { + return nil, fmt.Errorf("tool: new tool error: %w", ErrEmptyMessage) + } + if err := validateErrorCategory(category); err != nil { + return nil, fmt.Errorf("tool: new tool error: %w", err) + } + return &Error{Category: category, Message: message, Retryable: retryable, Details: details}, nil +} + +// validateErrorCategory rejects ErrorCategoryUnspecified, the +// kernel-only process_crashed category, and any out-of-range value; every +// other declared category is valid. +func validateErrorCategory(c ErrorCategory) error { + switch c { + case ErrorCategoryUnspecified: + return ErrUnspecifiedCategory + case toolErrorCategoryProcessCrashed: + return ErrProcessCrashedCategory + case ErrorCategoryInvalidArguments, + ErrorCategoryNotFound, + ErrorCategoryPermissionDenied, + ErrorCategoryExecutionFailed, + ErrorCategoryTimeout, + ErrorCategoryConcurrencyConflict, + ErrorCategoryCancelled, + ErrorCategoryUnknown: + return nil + default: + return fmt.Errorf("%w: %d", ErrUnspecifiedCategory, int(c)) + } +} + +// GRPCCode maps a ErrorCategory to the grpc/codes.Code that best +// represents it when a Error must cross the plugin boundary as a gRPC +// status — as opposed to traveling in-band as an Invoke stream's terminal +// `error` Event (the common case, see stream.go), which never becomes +// a gRPC status at all. +// +// Two entries are judgment calls, recorded here per the task's request: +// +// - ErrorCategoryExecutionFailed maps to codes.Internal. +// execution_failed means the operation ran and failed on its own +// terms (non-zero exit, compiler error, HTTP 4xx/5xx) rather than the +// RPC call itself being malformed, so none of the more specific +// argument/lookup/permission/deadline codes fit; codes.Internal is +// .claude/rules/grpc.md's own prescribed fallback for "unmapped", and +// execution_failed is exactly that from the transport's point of +// view — the category exists precisely so this case is NOT surfaced +// as a protocol-level failure in the first place (conformance.md's +// reaction table: "Ordinary tool_result content, not a +// protocol-level failure"), so this mapping is only ever exercised +// on the rare path where an execution_failed Error has to cross +// as a status anyway (e.g. a hand-rolled Configure-time check). +// - ErrorCategoryConcurrencyConflict maps to codes.Aborted, not +// codes.FailedPrecondition. codes.Aborted's documented meaning ("the +// operation was aborted ... due to a concurrency issue ... the +// client should retry at a higher level") is a closer textual match +// than codes.FailedPrecondition's ("the client should not retry +// until the system state has been explicitly fixed") — +// concurrency_conflict is specifically retryable-after-serialization +// (conformance.md), which is Aborted's documented retry semantics. +func GRPCCode(category ErrorCategory) codes.Code { + switch category { + case ErrorCategoryInvalidArguments: + return codes.InvalidArgument + case ErrorCategoryNotFound: + return codes.NotFound + case ErrorCategoryPermissionDenied: + return codes.PermissionDenied + case ErrorCategoryExecutionFailed: + return codes.Internal + case ErrorCategoryTimeout: + return codes.DeadlineExceeded + case ErrorCategoryConcurrencyConflict: + return codes.Aborted + case ErrorCategoryCancelled: + return codes.Canceled + case toolErrorCategoryProcessCrashed: + return codes.Unavailable + default: // Unspecified, Unknown, or any out-of-range value. + return codes.Internal + } +} + +// ToStatusError converts te into a gRPC status error suitable for crossing +// the plugin boundary via plugin.StatusError, mapping te.Category to a +// codes.Code via GRPCCode and te.Details to string metadata. Use this for +// an error that must fail the RPC itself (Configure, GetSchema, Render, +// Preview) — never for Invoke's own result/error terminal events, which +// travel in-band as a Event (see stream.go) rather than as a gRPC +// status. +func ToStatusError(te *Error) error { + if te == nil { + return plugin.StatusError(codes.Internal, errorDomain, "nil_tool_error", "tool: nil Error", nil) + } + return plugin.StatusError(GRPCCode(te.Category), errorDomain, strings.ToLower(te.Category.String()), te.Message, detailsToMetadata(te.Details)) +} + +// detailsToMetadata stringifies details for plugin.StatusError's metadata +// parameter, which is typed map[string]string. Returns nil for an empty +// map so ToStatusError never attaches an empty, noise-only metadata map. +func detailsToMetadata(details map[string]any) map[string]string { + if len(details) == 0 { + return nil + } + out := make(map[string]string, len(details)) + for k, v := range details { + out[k] = fmt.Sprintf("%v", v) + } + return out +} diff --git a/pkg/tool/errors_test.go b/pkg/tool/errors_test.go new file mode 100644 index 0000000..19524c0 --- /dev/null +++ b/pkg/tool/errors_test.go @@ -0,0 +1,204 @@ +package tool_test + +import ( + "errors" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/pluggableharness/agent/pkg/tool" +) + +func TestErrorCategoryString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + category tool.ErrorCategory + want string + }{ + {"unspecified", tool.ErrorCategoryUnspecified, "unspecified"}, + {"invalid_arguments", tool.ErrorCategoryInvalidArguments, "invalid_arguments"}, + {"not_found", tool.ErrorCategoryNotFound, "not_found"}, + {"permission_denied", tool.ErrorCategoryPermissionDenied, "permission_denied"}, + {"execution_failed", tool.ErrorCategoryExecutionFailed, "execution_failed"}, + {"timeout", tool.ErrorCategoryTimeout, "timeout"}, + {"concurrency_conflict", tool.ErrorCategoryConcurrencyConflict, "concurrency_conflict"}, + {"cancelled", tool.ErrorCategoryCancelled, "cancelled"}, + {"unknown", tool.ErrorCategoryUnknown, "unknown"}, + {"out of range", tool.ErrorCategory(99), "unrecognized"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.category.String(); got != tt.want { + t.Errorf("ErrorCategory(%d).String() = %q, want %q", tt.category, got, tt.want) + } + }) + } +} + +func TestNewError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + category tool.ErrorCategory + message string + retryable bool + details map[string]any + wantErr error + }{ + { + name: "valid invalid_arguments", + category: tool.ErrorCategoryInvalidArguments, + message: "bad path", + retryable: false, + }, + { + name: "valid timeout retryable", + category: tool.ErrorCategoryTimeout, + message: "deadline exceeded", + retryable: true, + details: map[string]any{"elapsed_ms": 5000}, + }, + { + name: "empty message rejected", + category: tool.ErrorCategoryNotFound, + message: "", + wantErr: tool.ErrEmptyMessage, + }, + { + name: "unspecified category rejected", + category: tool.ErrorCategoryUnspecified, + message: "whatever", + wantErr: tool.ErrUnspecifiedCategory, + }, + { + // process_crashed's underlying int value (8) is not + // exported, but ErrorCategory is just an int — a + // caller can still name the numeric value directly. + // NewError MUST refuse it regardless. + name: "process_crashed numeric value rejected", + category: tool.ErrorCategory(8), + message: "subprocess died", + wantErr: tool.ErrProcessCrashedCategory, + }, + { + name: "out of range category rejected", + category: tool.ErrorCategory(99), + message: "whatever", + wantErr: tool.ErrUnspecifiedCategory, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := tool.NewError(tt.category, tt.message, tt.retryable, tt.details) + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("NewError(%v, %q, ...) error = %v, want wrapping %v", tt.category, tt.message, err, tt.wantErr) + } + if got != nil { + t.Errorf("NewError(%v, %q, ...) = %v, want nil on error", tt.category, tt.message, got) + } + return + } + if err != nil { + t.Fatalf("NewError(%v, %q, ...) unexpected error: %v", tt.category, tt.message, err) + } + if got.Category != tt.category { + t.Errorf("Category = %v, want %v", got.Category, tt.category) + } + if got.Message != tt.message { + t.Errorf("Message = %q, want %q", got.Message, tt.message) + } + if got.Retryable != tt.retryable { + t.Errorf("Retryable = %v, want %v", got.Retryable, tt.retryable) + } + }) + } +} + +func TestErrorImplementsError(t *testing.T) { + t.Parallel() + + te, err := tool.NewError(tool.ErrorCategoryExecutionFailed, "compile failed", false, nil) + if err != nil { + t.Fatalf("NewError: %v", err) + } + var asErr error = te + if asErr.Error() != "compile failed" { + t.Errorf("te.Error() = %q, want %q", asErr.Error(), "compile failed") + } + + var nilTE *tool.Error + if nilTE.Error() != "" { + t.Errorf("nil Error.Error() = %q, want empty string", nilTE.Error()) + } +} + +func TestGRPCCode(t *testing.T) { + t.Parallel() + + tests := []struct { + category tool.ErrorCategory + want codes.Code + }{ + {tool.ErrorCategoryInvalidArguments, codes.InvalidArgument}, + {tool.ErrorCategoryNotFound, codes.NotFound}, + {tool.ErrorCategoryPermissionDenied, codes.PermissionDenied}, + {tool.ErrorCategoryExecutionFailed, codes.Internal}, + {tool.ErrorCategoryTimeout, codes.DeadlineExceeded}, + {tool.ErrorCategoryConcurrencyConflict, codes.Aborted}, + {tool.ErrorCategoryCancelled, codes.Canceled}, + {tool.ErrorCategoryUnknown, codes.Internal}, + {tool.ErrorCategoryUnspecified, codes.Internal}, + {tool.ErrorCategory(99), codes.Internal}, + } + for _, tt := range tests { + t.Run(tt.category.String(), func(t *testing.T) { + t.Parallel() + if got := tool.GRPCCode(tt.category); got != tt.want { + t.Errorf("GRPCCode(%v) = %v, want %v", tt.category, got, tt.want) + } + }) + } +} + +func TestToStatusError(t *testing.T) { + t.Parallel() + + t.Run("nil", func(t *testing.T) { + t.Parallel() + err := tool.ToStatusError(nil) + st, ok := status.FromError(err) + if !ok { + t.Fatalf("ToStatusError(nil) did not produce a *status.Status: %v", err) + } + if st.Code() != codes.Internal { + t.Errorf("ToStatusError(nil) code = %v, want %v", st.Code(), codes.Internal) + } + }) + + t.Run("with details", func(t *testing.T) { + t.Parallel() + te, err := tool.NewError(tool.ErrorCategoryNotFound, "no such file", false, map[string]any{"path": "/tmp/x"}) + if err != nil { + t.Fatalf("NewError: %v", err) + } + gotErr := tool.ToStatusError(te) + st, ok := status.FromError(gotErr) + if !ok { + t.Fatalf("ToStatusError did not produce a *status.Status: %v", gotErr) + } + if st.Code() != codes.NotFound { + t.Errorf("code = %v, want %v", st.Code(), codes.NotFound) + } + if st.Message() != "no such file" { + t.Errorf("message = %q, want %q", st.Message(), "no such file") + } + }) +} diff --git a/pkg/tool/helpers_test.go b/pkg/tool/helpers_test.go new file mode 100644 index 0000000..75f0c83 --- /dev/null +++ b/pkg/tool/helpers_test.go @@ -0,0 +1,139 @@ +package tool_test + +import ( + "context" + "net" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" + schemav1 "github.com/pluggableharness/agent/pkg/schema/proto/v1" + "github.com/pluggableharness/agent/pkg/tool" + toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" +) + +// fakeProvider is a hand-written tool.Provider fake (go-testing.md: fakes, +// not mocking frameworks). Each method's behavior is controlled by a +// caller-set func field; a nil field falls through to a harmless default. +type fakeProvider struct { + schemaFunc func(ctx context.Context) ([]*tool.Schema, error) + configureFunc func(ctx context.Context, config map[string]any) error + invokeFunc func(ctx context.Context, call *tool.Call, stream *tool.Stream) error +} + +func (f *fakeProvider) Schema(ctx context.Context) ([]*tool.Schema, error) { + if f.schemaFunc != nil { + return f.schemaFunc(ctx) + } + return nil, nil +} + +func (f *fakeProvider) Configure(ctx context.Context, config map[string]any) error { + if f.configureFunc != nil { + return f.configureFunc(ctx, config) + } + return nil +} + +func (f *fakeProvider) Invoke(ctx context.Context, call *tool.Call, stream *tool.Stream) error { + if f.invokeFunc != nil { + return f.invokeFunc(ctx, call, stream) + } + return stream.Send(tool.NewResultEvent(map[string]any{})) +} + +var _ tool.Provider = (*fakeProvider)(nil) + +// fakeFullProvider embeds fakeProvider and additionally implements every +// optional interface this package defines (Renderer, Previewer, +// ConfigSchemaProvider, SlashCommandProvider, HookPointProvider), each +// controlled by its own func field — used by tests exercising the +// optional-capability paths. Kept as a distinct type from fakeProvider so +// tests can also exercise the "provider does not implement this optional +// interface" fallback paths against a plain *fakeProvider. +type fakeFullProvider struct { + *fakeProvider + + renderFunc func(ctx context.Context, payload []byte, schemaVersion string) (*renderv1.RenderTree, error) + previewFunc func(ctx context.Context, call *tool.Call) (*renderv1.RenderTree, error) + configSchemaFunc func() (*configv1.ConfigSchema, error) + slashCommands []*commonv1.PromptExpansionSpec + hookPoints []commonv1.HookPoint +} + +func (f *fakeFullProvider) Render(ctx context.Context, payload []byte, schemaVersion string) (*renderv1.RenderTree, error) { + return f.renderFunc(ctx, payload, schemaVersion) +} + +func (f *fakeFullProvider) Preview(ctx context.Context, call *tool.Call) (*renderv1.RenderTree, error) { + return f.previewFunc(ctx, call) +} + +func (f *fakeFullProvider) ConfigSchema() (*configv1.ConfigSchema, error) { + return f.configSchemaFunc() +} + +func (f *fakeFullProvider) SlashCommands() []*commonv1.PromptExpansionSpec { + return f.slashCommands +} + +func (f *fakeFullProvider) SupportedHookPoints() []commonv1.HookPoint { + return f.hookPoints +} + +var ( + _ tool.Provider = (*fakeFullProvider)(nil) + _ tool.Renderer = (*fakeFullProvider)(nil) + _ tool.Previewer = (*fakeFullProvider)(nil) + _ tool.ConfigSchemaProvider = (*fakeFullProvider)(nil) + _ tool.SlashCommandProvider = (*fakeFullProvider)(nil) + _ tool.HookPointProvider = (*fakeFullProvider)(nil) +) + +// validSchema returns a minimally valid *tool.Schema for tests +// that just need something toProtoSchema accepts. +func validSchema(name string) *tool.Schema { + return &tool.Schema{ + Name: name, + Kind: tool.KindDataSource, + Risk: tool.RiskClassReadOnly, + Description: "a test operation", + InputSchema: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_OBJECT}, + OutputSchema: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_OBJECT}, + Streaming: false, + Concurrency: &tool.ConcurrencySpec{Safe: true}, + } +} + +// newTestClient starts a *tool.Service wrapping p on an in-memory bufconn +// listener and returns a real toolv1.ToolServiceClient dialed against it — +// a real gRPC round trip (go-testing.md), not a hand-rolled interface +// fake, mirroring pkg/kernel/helpers_test.go's newTestClient. +func newTestClient(t *testing.T, p tool.Provider) toolv1.ToolServiceClient { + t.Helper() + + svc := tool.NewService(p, plugin.Identity{Name: "fake-tool", Version: "0.0.1", Source: "local/fake"}, plugin.NewCallback()) + + const bufSize = 1 << 20 + lis := bufconn.Listen(bufSize) + + gs := grpc.NewServer() + svc.Register(gs) + go func() { _ = gs.Serve(lis) }() + t.Cleanup(gs.Stop) + + dialer := func(context.Context, string) (net.Conn, error) { return lis.Dial() } + conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(dialer), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("grpc.NewClient: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + return toolv1.NewToolServiceClient(conn) +} diff --git a/pkg/tool/proto/v1/errors.pb.go b/pkg/tool/proto/v1/errors.pb.go new file mode 100644 index 0000000..10e9d7e --- /dev/null +++ b/pkg/tool/proto/v1/errors.pb.go @@ -0,0 +1,273 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/tool/v1/errors.proto + +package toolv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// ToolErrorCategory classifies why an Invoke call failed, per tool.md §8. +// Deliberately distinct from model.md §8's ModelErrorCategory — there +// is no RATE_LIMITED or CONTEXT_LENGTH_EXCEEDED here, those are +// model-vendor concepts. The two enums MUST NOT be merged even though the +// surrounding error-envelope shape (category/message/retryable) is +// parallel. +type ToolErrorCategory int32 + +const ( + // Zero value. Never valid for a real error; its presence on the wire + // means a caller forgot to set the field. + ToolErrorCategory_TOOL_ERROR_CATEGORY_UNSPECIFIED ToolErrorCategory = 0 + // Input failed input_schema validation. + ToolErrorCategory_TOOL_ERROR_CATEGORY_INVALID_ARGUMENTS ToolErrorCategory = 1 + // The target of the operation doesn't exist (path, URL, symbol, ...). + ToolErrorCategory_TOOL_ERROR_CATEGORY_NOT_FOUND ToolErrorCategory = 2 + // OS/policy denied the underlying operation. + ToolErrorCategory_TOOL_ERROR_CATEGORY_PERMISSION_DENIED ToolErrorCategory = 3 + // The operation ran but failed on its own terms (non-zero exit, compiler + // error, HTTP 4xx/5xx) — not a plugin bug. + ToolErrorCategory_TOOL_ERROR_CATEGORY_EXECUTION_FAILED ToolErrorCategory = 4 + // Exceeded a plugin- or kernel-enforced deadline. + ToolErrorCategory_TOOL_ERROR_CATEGORY_TIMEOUT ToolErrorCategory = 5 + // The provider detected a conflicting concurrent call it could not + // serialize itself (see ConcurrencySpec) — signals the kernel to retry + // serialized. + ToolErrorCategory_TOOL_ERROR_CATEGORY_CONCURRENCY_CONFLICT ToolErrorCategory = 6 + // The stream was cancelled per tool.md §1 — not "an error" in the + // failure sense; kept distinct so the kernel doesn't surface it to the + // model as a tool failure when the whole turn is being aborted anyway. + ToolErrorCategory_TOOL_ERROR_CATEGORY_CANCELLED ToolErrorCategory = 7 + // The plugin subprocess itself died mid-Invoke (transport error, not a + // graceful error event the plugin chose to emit). MUST be + // kernel-synthesized only — a plugin process that crashes obviously + // cannot emit this itself; the kernel detects the crash and synthesizes + // this category. + ToolErrorCategory_TOOL_ERROR_CATEGORY_PROCESS_CRASHED ToolErrorCategory = 8 + // Anything else. MUST include the raw underlying error in + // ToolError.details. + ToolErrorCategory_TOOL_ERROR_CATEGORY_UNKNOWN ToolErrorCategory = 9 +) + +// Enum value maps for ToolErrorCategory. +var ( + ToolErrorCategory_name = map[int32]string{ + 0: "TOOL_ERROR_CATEGORY_UNSPECIFIED", + 1: "TOOL_ERROR_CATEGORY_INVALID_ARGUMENTS", + 2: "TOOL_ERROR_CATEGORY_NOT_FOUND", + 3: "TOOL_ERROR_CATEGORY_PERMISSION_DENIED", + 4: "TOOL_ERROR_CATEGORY_EXECUTION_FAILED", + 5: "TOOL_ERROR_CATEGORY_TIMEOUT", + 6: "TOOL_ERROR_CATEGORY_CONCURRENCY_CONFLICT", + 7: "TOOL_ERROR_CATEGORY_CANCELLED", + 8: "TOOL_ERROR_CATEGORY_PROCESS_CRASHED", + 9: "TOOL_ERROR_CATEGORY_UNKNOWN", + } + ToolErrorCategory_value = map[string]int32{ + "TOOL_ERROR_CATEGORY_UNSPECIFIED": 0, + "TOOL_ERROR_CATEGORY_INVALID_ARGUMENTS": 1, + "TOOL_ERROR_CATEGORY_NOT_FOUND": 2, + "TOOL_ERROR_CATEGORY_PERMISSION_DENIED": 3, + "TOOL_ERROR_CATEGORY_EXECUTION_FAILED": 4, + "TOOL_ERROR_CATEGORY_TIMEOUT": 5, + "TOOL_ERROR_CATEGORY_CONCURRENCY_CONFLICT": 6, + "TOOL_ERROR_CATEGORY_CANCELLED": 7, + "TOOL_ERROR_CATEGORY_PROCESS_CRASHED": 8, + "TOOL_ERROR_CATEGORY_UNKNOWN": 9, + } +) + +func (x ToolErrorCategory) Enum() *ToolErrorCategory { + p := new(ToolErrorCategory) + *p = x + return p +} + +func (x ToolErrorCategory) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ToolErrorCategory) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_tool_v1_errors_proto_enumTypes[0].Descriptor() +} + +func (ToolErrorCategory) Type() protoreflect.EnumType { + return &file_pluggableharness_tool_v1_errors_proto_enumTypes[0] +} + +func (x ToolErrorCategory) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ToolErrorCategory.Descriptor instead. +func (ToolErrorCategory) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_errors_proto_rawDescGZIP(), []int{0} +} + +// ToolError is the terminal, failed outcome of an Invoke call. +type ToolError struct { + state protoimpl.MessageState `protogen:"open.v1"` + // MUST. + Category ToolErrorCategory `protobuf:"varint,1,opt,name=category,proto3,enum=pluggableharness.tool.v1.ToolErrorCategory" json:"category,omitempty"` + // MUST — human-readable. + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + // MUST. + Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"` + // MAY — provider-specific structured detail. MUST include the raw + // underlying error for category TOOL_ERROR_CATEGORY_UNKNOWN. + Details *structpb.Struct `protobuf:"bytes,4,opt,name=details,proto3,oneof" json:"details,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ToolError) Reset() { + *x = ToolError{} + mi := &file_pluggableharness_tool_v1_errors_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ToolError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolError) ProtoMessage() {} + +func (x *ToolError) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_errors_proto_msgTypes[0] + 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 ToolError.ProtoReflect.Descriptor instead. +func (*ToolError) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_errors_proto_rawDescGZIP(), []int{0} +} + +func (x *ToolError) GetCategory() ToolErrorCategory { + if x != nil { + return x.Category + } + return ToolErrorCategory_TOOL_ERROR_CATEGORY_UNSPECIFIED +} + +func (x *ToolError) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ToolError) GetRetryable() bool { + if x != nil { + return x.Retryable + } + return false +} + +func (x *ToolError) GetDetails() *structpb.Struct { + if x != nil { + return x.Details + } + return nil +} + +var File_pluggableharness_tool_v1_errors_proto protoreflect.FileDescriptor + +const file_pluggableharness_tool_v1_errors_proto_rawDesc = "" + + "\n" + + "%pluggableharness/tool/v1/errors.proto\x12\x18pluggableharness.tool.v1\x1a\x1cgoogle/protobuf/struct.proto\"\xd0\x01\n" + + "\tToolError\x12G\n" + + "\bcategory\x18\x01 \x01(\x0e2+.pluggableharness.tool.v1.ToolErrorCategoryR\bcategory\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1c\n" + + "\tretryable\x18\x03 \x01(\bR\tretryable\x126\n" + + "\adetails\x18\x04 \x01(\v2\x17.google.protobuf.StructH\x00R\adetails\x88\x01\x01B\n" + + "\n" + + "\b_details*\x97\x03\n" + + "\x11ToolErrorCategory\x12#\n" + + "\x1fTOOL_ERROR_CATEGORY_UNSPECIFIED\x10\x00\x12)\n" + + "%TOOL_ERROR_CATEGORY_INVALID_ARGUMENTS\x10\x01\x12!\n" + + "\x1dTOOL_ERROR_CATEGORY_NOT_FOUND\x10\x02\x12)\n" + + "%TOOL_ERROR_CATEGORY_PERMISSION_DENIED\x10\x03\x12(\n" + + "$TOOL_ERROR_CATEGORY_EXECUTION_FAILED\x10\x04\x12\x1f\n" + + "\x1bTOOL_ERROR_CATEGORY_TIMEOUT\x10\x05\x12,\n" + + "(TOOL_ERROR_CATEGORY_CONCURRENCY_CONFLICT\x10\x06\x12!\n" + + "\x1dTOOL_ERROR_CATEGORY_CANCELLED\x10\a\x12'\n" + + "#TOOL_ERROR_CATEGORY_PROCESS_CRASHED\x10\b\x12\x1f\n" + + "\x1bTOOL_ERROR_CATEGORY_UNKNOWN\x10\tB pluggableharness.tool.v1.ToolErrorCategory + 2, // 1: pluggableharness.tool.v1.ToolError.details:type_name -> google.protobuf.Struct + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_pluggableharness_tool_v1_errors_proto_init() } +func file_pluggableharness_tool_v1_errors_proto_init() { + if File_pluggableharness_tool_v1_errors_proto != nil { + return + } + file_pluggableharness_tool_v1_errors_proto_msgTypes[0].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_tool_v1_errors_proto_rawDesc), len(file_pluggableharness_tool_v1_errors_proto_rawDesc)), + NumEnums: 1, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_tool_v1_errors_proto_goTypes, + DependencyIndexes: file_pluggableharness_tool_v1_errors_proto_depIdxs, + EnumInfos: file_pluggableharness_tool_v1_errors_proto_enumTypes, + MessageInfos: file_pluggableharness_tool_v1_errors_proto_msgTypes, + }.Build() + File_pluggableharness_tool_v1_errors_proto = out.File + file_pluggableharness_tool_v1_errors_proto_goTypes = nil + file_pluggableharness_tool_v1_errors_proto_depIdxs = nil +} diff --git a/pkg/tool/proto/v1/events.pb.go b/pkg/tool/proto/v1/events.pb.go new file mode 100644 index 0000000..a8970f8 --- /dev/null +++ b/pkg/tool/proto/v1/events.pb.go @@ -0,0 +1,617 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/tool/v1/events.proto + +package toolv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// OutputStream distinguishes which underlying stream an OutputChunk came +// from. +type OutputStream int32 + +const ( + // Zero value. Never valid for a real chunk; its presence on the wire + // means a caller forgot to set the field. + OutputStream_OUTPUT_STREAM_UNSPECIFIED OutputStream = 0 + // Standard output. + OutputStream_OUTPUT_STREAM_STDOUT OutputStream = 1 + // Standard error. + OutputStream_OUTPUT_STREAM_STDERR OutputStream = 2 +) + +// Enum value maps for OutputStream. +var ( + OutputStream_name = map[int32]string{ + 0: "OUTPUT_STREAM_UNSPECIFIED", + 1: "OUTPUT_STREAM_STDOUT", + 2: "OUTPUT_STREAM_STDERR", + } + OutputStream_value = map[string]int32{ + "OUTPUT_STREAM_UNSPECIFIED": 0, + "OUTPUT_STREAM_STDOUT": 1, + "OUTPUT_STREAM_STDERR": 2, + } +) + +func (x OutputStream) Enum() *OutputStream { + p := new(OutputStream) + *p = x + return p +} + +func (x OutputStream) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (OutputStream) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_tool_v1_events_proto_enumTypes[0].Descriptor() +} + +func (OutputStream) Type() protoreflect.EnumType { + return &file_pluggableharness_tool_v1_events_proto_enumTypes[0] +} + +func (x OutputStream) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use OutputStream.Descriptor instead. +func (OutputStream) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_events_proto_rawDescGZIP(), []int{0} +} + +// InvokeResponse wraps one message in the stream Invoke returns. A thin +// per-RPC envelope around ToolEvent, which keeps its own rich structure +// independent of the RPC signature. +type InvokeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The event. + Event *ToolEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InvokeResponse) Reset() { + *x = InvokeResponse{} + mi := &file_pluggableharness_tool_v1_events_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InvokeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InvokeResponse) ProtoMessage() {} + +func (x *InvokeResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_events_proto_msgTypes[0] + 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 InvokeResponse.ProtoReflect.Descriptor instead. +func (*InvokeResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_events_proto_rawDescGZIP(), []int{0} +} + +func (x *InvokeResponse) GetEvent() *ToolEvent { + if x != nil { + return x.Event + } + return nil +} + +// ToolEvent is one message in the stream Invoke returns, per tool.md §4. +// Exactly one of `result`/`error` closes the stream; `output_chunk`, +// `progress`, and `partial_result` MAY each appear zero or more times +// before it; `exit_status` MAY appear at most once. The relative order of +// `output_chunk` events MUST be preserved by the transport. +type ToolEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *ToolEvent_OutputChunk_ + // *ToolEvent_Progress_ + // *ToolEvent_PartialResult_ + // *ToolEvent_ExitStatus_ + // *ToolEvent_Result + // *ToolEvent_Error + Event isToolEvent_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ToolEvent) Reset() { + *x = ToolEvent{} + mi := &file_pluggableharness_tool_v1_events_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ToolEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolEvent) ProtoMessage() {} + +func (x *ToolEvent) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_events_proto_msgTypes[1] + 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 ToolEvent.ProtoReflect.Descriptor instead. +func (*ToolEvent) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_events_proto_rawDescGZIP(), []int{1} +} + +func (x *ToolEvent) GetEvent() isToolEvent_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *ToolEvent) GetOutputChunk() *ToolEvent_OutputChunk { + if x != nil { + if x, ok := x.Event.(*ToolEvent_OutputChunk_); ok { + return x.OutputChunk + } + } + return nil +} + +func (x *ToolEvent) GetProgress() *ToolEvent_Progress { + if x != nil { + if x, ok := x.Event.(*ToolEvent_Progress_); ok { + return x.Progress + } + } + return nil +} + +func (x *ToolEvent) GetPartialResult() *ToolEvent_PartialResult { + if x != nil { + if x, ok := x.Event.(*ToolEvent_PartialResult_); ok { + return x.PartialResult + } + } + return nil +} + +func (x *ToolEvent) GetExitStatus() *ToolEvent_ExitStatus { + if x != nil { + if x, ok := x.Event.(*ToolEvent_ExitStatus_); ok { + return x.ExitStatus + } + } + return nil +} + +func (x *ToolEvent) GetResult() *ToolResult { + if x != nil { + if x, ok := x.Event.(*ToolEvent_Result); ok { + return x.Result + } + } + return nil +} + +func (x *ToolEvent) GetError() *ToolError { + if x != nil { + if x, ok := x.Event.(*ToolEvent_Error); ok { + return x.Error + } + } + return nil +} + +type isToolEvent_Event interface { + isToolEvent_Event() +} + +type ToolEvent_OutputChunk_ struct { + // Incremental raw output from a process-backed operation. + OutputChunk *ToolEvent_OutputChunk `protobuf:"bytes,1,opt,name=output_chunk,json=outputChunk,proto3,oneof"` +} + +type ToolEvent_Progress_ struct { + // A human-readable progress update. + Progress *ToolEvent_Progress `protobuf:"bytes,2,opt,name=progress,proto3,oneof"` +} + +type ToolEvent_PartialResult_ struct { + // Incremental structured output, e.g. search hits as they're found. + PartialResult *ToolEvent_PartialResult `protobuf:"bytes,3,opt,name=partial_result,json=partialResult,proto3,oneof"` +} + +type ToolEvent_ExitStatus_ struct { + // The exit status of a process-backed operation's child process. + ExitStatus *ToolEvent_ExitStatus `protobuf:"bytes,4,opt,name=exit_status,json=exitStatus,proto3,oneof"` +} + +type ToolEvent_Result struct { + // The terminal, successful result of this call. + Result *ToolResult `protobuf:"bytes,5,opt,name=result,proto3,oneof"` +} + +type ToolEvent_Error struct { + // The terminal, failed result of this call. + Error *ToolError `protobuf:"bytes,6,opt,name=error,proto3,oneof"` +} + +func (*ToolEvent_OutputChunk_) isToolEvent_Event() {} + +func (*ToolEvent_Progress_) isToolEvent_Event() {} + +func (*ToolEvent_PartialResult_) isToolEvent_Event() {} + +func (*ToolEvent_ExitStatus_) isToolEvent_Event() {} + +func (*ToolEvent_Result) isToolEvent_Event() {} + +func (*ToolEvent_Error) isToolEvent_Event() {} + +// OutputChunk carries one slice of raw stdout/stderr-shaped output from a +// process-backed operation. +type ToolEvent_OutputChunk struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Which stream this chunk came from. + Stream OutputStream `protobuf:"varint,1,opt,name=stream,proto3,enum=pluggableharness.tool.v1.OutputStream" json:"stream,omitempty"` + // The chunk's raw bytes. + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ToolEvent_OutputChunk) Reset() { + *x = ToolEvent_OutputChunk{} + mi := &file_pluggableharness_tool_v1_events_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ToolEvent_OutputChunk) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolEvent_OutputChunk) ProtoMessage() {} + +func (x *ToolEvent_OutputChunk) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_events_proto_msgTypes[2] + 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 ToolEvent_OutputChunk.ProtoReflect.Descriptor instead. +func (*ToolEvent_OutputChunk) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_events_proto_rawDescGZIP(), []int{1, 0} +} + +func (x *ToolEvent_OutputChunk) GetStream() OutputStream { + if x != nil { + return x.Stream + } + return OutputStream_OUTPUT_STREAM_UNSPECIFIED +} + +func (x *ToolEvent_OutputChunk) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +// Progress carries a human-readable status update for a long-running +// call. +type ToolEvent_Progress struct { + state protoimpl.MessageState `protogen:"open.v1"` + // A human-readable description of the current step. + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + // How far through the operation this call is, in [0.0, 1.0]. Absent + // means the provider cannot estimate completion fraction. + FractionComplete *float64 `protobuf:"fixed64,2,opt,name=fraction_complete,json=fractionComplete,proto3,oneof" json:"fraction_complete,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ToolEvent_Progress) Reset() { + *x = ToolEvent_Progress{} + mi := &file_pluggableharness_tool_v1_events_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ToolEvent_Progress) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolEvent_Progress) ProtoMessage() {} + +func (x *ToolEvent_Progress) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_events_proto_msgTypes[3] + 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 ToolEvent_Progress.ProtoReflect.Descriptor instead. +func (*ToolEvent_Progress) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_events_proto_rawDescGZIP(), []int{1, 1} +} + +func (x *ToolEvent_Progress) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ToolEvent_Progress) GetFractionComplete() float64 { + if x != nil && x.FractionComplete != nil { + return *x.FractionComplete + } + return 0 +} + +// PartialResult carries incremental structured output before the +// terminal result, e.g. search hits as they're found. +type ToolEvent_PartialResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The incremental structured payload. + Payload *structpb.Struct `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ToolEvent_PartialResult) Reset() { + *x = ToolEvent_PartialResult{} + mi := &file_pluggableharness_tool_v1_events_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ToolEvent_PartialResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolEvent_PartialResult) ProtoMessage() {} + +func (x *ToolEvent_PartialResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_events_proto_msgTypes[4] + 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 ToolEvent_PartialResult.ProtoReflect.Descriptor instead. +func (*ToolEvent_PartialResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_events_proto_rawDescGZIP(), []int{1, 2} +} + +func (x *ToolEvent_PartialResult) GetPayload() *structpb.Struct { + if x != nil { + return x.Payload + } + return nil +} + +// ExitStatus carries a process-backed operation's child process exit +// information. exec-family tools only — a provider for a non-process- +// backed tool (file read, grep, web fetch) MUST NOT emit this. Appears +// at most once per Invoke stream. +type ToolEvent_ExitStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The child process's exit code. + ExitCode int32 `protobuf:"varint,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + // The signal that terminated the child process, if any. Absent means + // the process exited normally (exit_code is meaningful on its own). + Signal *string `protobuf:"bytes,2,opt,name=signal,proto3,oneof" json:"signal,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ToolEvent_ExitStatus) Reset() { + *x = ToolEvent_ExitStatus{} + mi := &file_pluggableharness_tool_v1_events_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ToolEvent_ExitStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolEvent_ExitStatus) ProtoMessage() {} + +func (x *ToolEvent_ExitStatus) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_events_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 ToolEvent_ExitStatus.ProtoReflect.Descriptor instead. +func (*ToolEvent_ExitStatus) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_events_proto_rawDescGZIP(), []int{1, 3} +} + +func (x *ToolEvent_ExitStatus) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *ToolEvent_ExitStatus) GetSignal() string { + if x != nil && x.Signal != nil { + return *x.Signal + } + return "" +} + +var File_pluggableharness_tool_v1_events_proto protoreflect.FileDescriptor + +const file_pluggableharness_tool_v1_events_proto_rawDesc = "" + + "\n" + + "%pluggableharness/tool/v1/events.proto\x12\x18pluggableharness.tool.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a%pluggableharness/tool/v1/errors.proto\x1a$pluggableharness/tool/v1/types.proto\"K\n" + + "\x0eInvokeResponse\x129\n" + + "\x05event\x18\x01 \x01(\v2#.pluggableharness.tool.v1.ToolEventR\x05event\"\xca\x06\n" + + "\tToolEvent\x12T\n" + + "\foutput_chunk\x18\x01 \x01(\v2/.pluggableharness.tool.v1.ToolEvent.OutputChunkH\x00R\voutputChunk\x12J\n" + + "\bprogress\x18\x02 \x01(\v2,.pluggableharness.tool.v1.ToolEvent.ProgressH\x00R\bprogress\x12Z\n" + + "\x0epartial_result\x18\x03 \x01(\v21.pluggableharness.tool.v1.ToolEvent.PartialResultH\x00R\rpartialResult\x12Q\n" + + "\vexit_status\x18\x04 \x01(\v2..pluggableharness.tool.v1.ToolEvent.ExitStatusH\x00R\n" + + "exitStatus\x12>\n" + + "\x06result\x18\x05 \x01(\v2$.pluggableharness.tool.v1.ToolResultH\x00R\x06result\x12;\n" + + "\x05error\x18\x06 \x01(\v2#.pluggableharness.tool.v1.ToolErrorH\x00R\x05error\x1aa\n" + + "\vOutputChunk\x12>\n" + + "\x06stream\x18\x01 \x01(\x0e2&.pluggableharness.tool.v1.OutputStreamR\x06stream\x12\x12\n" + + "\x04data\x18\x02 \x01(\fR\x04data\x1al\n" + + "\bProgress\x12\x18\n" + + "\amessage\x18\x01 \x01(\tR\amessage\x120\n" + + "\x11fraction_complete\x18\x02 \x01(\x01H\x00R\x10fractionComplete\x88\x01\x01B\x14\n" + + "\x12_fraction_complete\x1aB\n" + + "\rPartialResult\x121\n" + + "\apayload\x18\x01 \x01(\v2\x17.google.protobuf.StructR\apayload\x1aQ\n" + + "\n" + + "ExitStatus\x12\x1b\n" + + "\texit_code\x18\x01 \x01(\x05R\bexitCode\x12\x1b\n" + + "\x06signal\x18\x02 \x01(\tH\x00R\x06signal\x88\x01\x01B\t\n" + + "\a_signalB\a\n" + + "\x05event*a\n" + + "\fOutputStream\x12\x1d\n" + + "\x19OUTPUT_STREAM_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14OUTPUT_STREAM_STDOUT\x10\x01\x12\x18\n" + + "\x14OUTPUT_STREAM_STDERR\x10\x02B pluggableharness.tool.v1.ToolEvent + 3, // 1: pluggableharness.tool.v1.ToolEvent.output_chunk:type_name -> pluggableharness.tool.v1.ToolEvent.OutputChunk + 4, // 2: pluggableharness.tool.v1.ToolEvent.progress:type_name -> pluggableharness.tool.v1.ToolEvent.Progress + 5, // 3: pluggableharness.tool.v1.ToolEvent.partial_result:type_name -> pluggableharness.tool.v1.ToolEvent.PartialResult + 6, // 4: pluggableharness.tool.v1.ToolEvent.exit_status:type_name -> pluggableharness.tool.v1.ToolEvent.ExitStatus + 7, // 5: pluggableharness.tool.v1.ToolEvent.result:type_name -> pluggableharness.tool.v1.ToolResult + 8, // 6: pluggableharness.tool.v1.ToolEvent.error:type_name -> pluggableharness.tool.v1.ToolError + 0, // 7: pluggableharness.tool.v1.ToolEvent.OutputChunk.stream:type_name -> pluggableharness.tool.v1.OutputStream + 9, // 8: pluggableharness.tool.v1.ToolEvent.PartialResult.payload:type_name -> google.protobuf.Struct + 9, // [9:9] is the sub-list for method output_type + 9, // [9:9] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name +} + +func init() { file_pluggableharness_tool_v1_events_proto_init() } +func file_pluggableharness_tool_v1_events_proto_init() { + if File_pluggableharness_tool_v1_events_proto != nil { + return + } + file_pluggableharness_tool_v1_errors_proto_init() + file_pluggableharness_tool_v1_types_proto_init() + file_pluggableharness_tool_v1_events_proto_msgTypes[1].OneofWrappers = []any{ + (*ToolEvent_OutputChunk_)(nil), + (*ToolEvent_Progress_)(nil), + (*ToolEvent_PartialResult_)(nil), + (*ToolEvent_ExitStatus_)(nil), + (*ToolEvent_Result)(nil), + (*ToolEvent_Error)(nil), + } + file_pluggableharness_tool_v1_events_proto_msgTypes[3].OneofWrappers = []any{} + file_pluggableharness_tool_v1_events_proto_msgTypes[5].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_tool_v1_events_proto_rawDesc), len(file_pluggableharness_tool_v1_events_proto_rawDesc)), + NumEnums: 1, + NumMessages: 6, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_tool_v1_events_proto_goTypes, + DependencyIndexes: file_pluggableharness_tool_v1_events_proto_depIdxs, + EnumInfos: file_pluggableharness_tool_v1_events_proto_enumTypes, + MessageInfos: file_pluggableharness_tool_v1_events_proto_msgTypes, + }.Build() + File_pluggableharness_tool_v1_events_proto = out.File + file_pluggableharness_tool_v1_events_proto_goTypes = nil + file_pluggableharness_tool_v1_events_proto_depIdxs = nil +} diff --git a/pkg/tool/proto/v1/rpc_request.pb.go b/pkg/tool/proto/v1/rpc_request.pb.go new file mode 100644 index 0000000..04e9454 --- /dev/null +++ b/pkg/tool/proto/v1/rpc_request.pb.go @@ -0,0 +1,375 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/tool/v1/rpc_request.proto + +package toolv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// GetSchemaRequest carries no fields — GetSchema takes no parameters. +type GetSchemaRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSchemaRequest) Reset() { + *x = GetSchemaRequest{} + mi := &file_pluggableharness_tool_v1_rpc_request_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSchemaRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSchemaRequest) ProtoMessage() {} + +func (x *GetSchemaRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_rpc_request_proto_msgTypes[0] + 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 GetSchemaRequest.ProtoReflect.Descriptor instead. +func (*GetSchemaRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_rpc_request_proto_rawDescGZIP(), []int{0} +} + +// ConfigureRequest wraps this provider's already-decoded agent.hcl config. +type ConfigureRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The provider-specific config, decoded from agent.hcl via the + // schema-to-cty bridge before crossing the wire. + Config *structpb.Struct `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureRequest) Reset() { + *x = ConfigureRequest{} + mi := &file_pluggableharness_tool_v1_rpc_request_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureRequest) ProtoMessage() {} + +func (x *ConfigureRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_rpc_request_proto_msgTypes[1] + 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 ConfigureRequest.ProtoReflect.Descriptor instead. +func (*ConfigureRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_rpc_request_proto_rawDescGZIP(), []int{1} +} + +func (x *ConfigureRequest) GetConfig() *structpb.Struct { + if x != nil { + return x.Config + } + return nil +} + +// InvokeRequest wraps the call to execute. A thin per-RPC envelope around +// ToolCall, which keeps its own rich structure independent of the RPC +// signature. +type InvokeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The call to execute. + Call *ToolCall `protobuf:"bytes,1,opt,name=call,proto3" json:"call,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InvokeRequest) Reset() { + *x = InvokeRequest{} + mi := &file_pluggableharness_tool_v1_rpc_request_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InvokeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InvokeRequest) ProtoMessage() {} + +func (x *InvokeRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_rpc_request_proto_msgTypes[2] + 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 InvokeRequest.ProtoReflect.Descriptor instead. +func (*InvokeRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_rpc_request_proto_rawDescGZIP(), []int{2} +} + +func (x *InvokeRequest) GetCall() *ToolCall { + if x != nil { + return x.Call + } + return nil +} + +// RenderRequest carries the opaque payload to render, per tool.md §7. See +// grpc.md's Emit->Render->Paint carve-out for why this field stays `bytes` +// rather than a strongly-typed message. +type RenderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The opaque emitted payload to render. + Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` + // The schema version the payload was emitted under, per + // ../frontend/render-tree.md#schema-versioning. Lets a Render + // implementation decode a payload emitted by an older build of this + // same plugin. + SchemaVersion string `protobuf:"bytes,2,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenderRequest) Reset() { + *x = RenderRequest{} + mi := &file_pluggableharness_tool_v1_rpc_request_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenderRequest) ProtoMessage() {} + +func (x *RenderRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_rpc_request_proto_msgTypes[3] + 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 RenderRequest.ProtoReflect.Descriptor instead. +func (*RenderRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_rpc_request_proto_rawDescGZIP(), []int{3} +} + +func (x *RenderRequest) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +func (x *RenderRequest) GetSchemaVersion() string { + if x != nil { + return x.SchemaVersion + } + return "" +} + +// PreviewRequest wraps the call to describe, per protocol.md#preview. +type PreviewRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The call Preview describes a dry run of. Same shape as an Invoke + // request; Preview MUST NOT execute it. + Call *ToolCall `protobuf:"bytes,1,opt,name=call,proto3" json:"call,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PreviewRequest) Reset() { + *x = PreviewRequest{} + mi := &file_pluggableharness_tool_v1_rpc_request_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PreviewRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PreviewRequest) ProtoMessage() {} + +func (x *PreviewRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_rpc_request_proto_msgTypes[4] + 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 PreviewRequest.ProtoReflect.Descriptor instead. +func (*PreviewRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_rpc_request_proto_rawDescGZIP(), []int{4} +} + +func (x *PreviewRequest) GetCall() *ToolCall { + if x != nil { + return x.Call + } + return nil +} + +// DescribeRequest carries no fields — Describe takes no parameters. +type DescribeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeRequest) Reset() { + *x = DescribeRequest{} + mi := &file_pluggableharness_tool_v1_rpc_request_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeRequest) ProtoMessage() {} + +func (x *DescribeRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_rpc_request_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 DescribeRequest.ProtoReflect.Descriptor instead. +func (*DescribeRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_rpc_request_proto_rawDescGZIP(), []int{5} +} + +var File_pluggableharness_tool_v1_rpc_request_proto protoreflect.FileDescriptor + +const file_pluggableharness_tool_v1_rpc_request_proto_rawDesc = "" + + "\n" + + "*pluggableharness/tool/v1/rpc_request.proto\x12\x18pluggableharness.tool.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a$pluggableharness/tool/v1/types.proto\"\x12\n" + + "\x10GetSchemaRequest\"C\n" + + "\x10ConfigureRequest\x12/\n" + + "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06config\"G\n" + + "\rInvokeRequest\x126\n" + + "\x04call\x18\x01 \x01(\v2\".pluggableharness.tool.v1.ToolCallR\x04call\"P\n" + + "\rRenderRequest\x12\x18\n" + + "\apayload\x18\x01 \x01(\fR\apayload\x12%\n" + + "\x0eschema_version\x18\x02 \x01(\tR\rschemaVersion\"H\n" + + "\x0ePreviewRequest\x126\n" + + "\x04call\x18\x01 \x01(\v2\".pluggableharness.tool.v1.ToolCallR\x04call\"\x11\n" + + "\x0fDescribeRequestB google.protobuf.Struct + 7, // 1: pluggableharness.tool.v1.InvokeRequest.call:type_name -> pluggableharness.tool.v1.ToolCall + 7, // 2: pluggableharness.tool.v1.PreviewRequest.call:type_name -> pluggableharness.tool.v1.ToolCall + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_pluggableharness_tool_v1_rpc_request_proto_init() } +func file_pluggableharness_tool_v1_rpc_request_proto_init() { + if File_pluggableharness_tool_v1_rpc_request_proto != nil { + return + } + file_pluggableharness_tool_v1_types_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_tool_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_tool_v1_rpc_request_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_tool_v1_rpc_request_proto_goTypes, + DependencyIndexes: file_pluggableharness_tool_v1_rpc_request_proto_depIdxs, + MessageInfos: file_pluggableharness_tool_v1_rpc_request_proto_msgTypes, + }.Build() + File_pluggableharness_tool_v1_rpc_request_proto = out.File + file_pluggableharness_tool_v1_rpc_request_proto_goTypes = nil + file_pluggableharness_tool_v1_rpc_request_proto_depIdxs = nil +} diff --git a/pkg/tool/proto/v1/rpc_response.pb.go b/pkg/tool/proto/v1/rpc_response.pb.go new file mode 100644 index 0000000..e0c8a2d --- /dev/null +++ b/pkg/tool/proto/v1/rpc_response.pb.go @@ -0,0 +1,387 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/tool/v1/rpc_response.proto + +package toolv1 + +import ( + v1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + v11 "github.com/pluggableharness/agent/pkg/config/proto/v1" + v12 "github.com/pluggableharness/agent/pkg/render/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// GetSchemaResponse is this provider's complete capability advertisement. +type GetSchemaResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // One entry per operation this plugin exposes. + Tools []*ToolSchema `protobuf:"bytes,1,rep,name=tools,proto3" json:"tools,omitempty"` + // Prompt-expansion slash commands this provider contributes, per + // tool.md §2.1. MAY be empty. A direct-invoke command — one that + // actually executes something — is declared by a slashcommand.v1 + // provider instead (specifications/slashcommand/), never here; a + // tool provider wanting a direct-invoke shortcut into one of its own + // operations implements SlashCommandService alongside ToolService in + // the same process (go-plugin muxes multiple services per connection, + // per hook.v1's precedent). + SlashCommands []*v1.PromptExpansionSpec `protobuf:"bytes,2,rep,name=slash_commands,json=slashCommands,proto3" json:"slash_commands,omitempty"` + // This provider's agent.hcl config schema, per configuration.md §4 — + // what fields Configure's request may be decoded from. + ConfigSchema *v11.ConfigSchema `protobuf:"bytes,3,opt,name=config_schema,json=configSchema,proto3" json:"config_schema,omitempty"` + // Which of the eight dispatchable hook points (hook/v1.HookPoint) this + // provider subscribes a HookSubscriberService to, per this provider's own + // agent.hcl hook{} blocks. Lets the kernel validate a hook{} declaration + // at config-load time instead of discovering an unsupported subscription + // only when that hook point first fires. + SupportedHookPoints []v1.HookPoint `protobuf:"varint,4,rep,packed,name=supported_hook_points,json=supportedHookPoints,proto3,enum=pluggableharness.common.v1.HookPoint" json:"supported_hook_points,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSchemaResponse) Reset() { + *x = GetSchemaResponse{} + mi := &file_pluggableharness_tool_v1_rpc_response_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSchemaResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSchemaResponse) ProtoMessage() {} + +func (x *GetSchemaResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_rpc_response_proto_msgTypes[0] + 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 GetSchemaResponse.ProtoReflect.Descriptor instead. +func (*GetSchemaResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_rpc_response_proto_rawDescGZIP(), []int{0} +} + +func (x *GetSchemaResponse) GetTools() []*ToolSchema { + if x != nil { + return x.Tools + } + return nil +} + +func (x *GetSchemaResponse) GetSlashCommands() []*v1.PromptExpansionSpec { + if x != nil { + return x.SlashCommands + } + return nil +} + +func (x *GetSchemaResponse) GetConfigSchema() *v11.ConfigSchema { + if x != nil { + return x.ConfigSchema + } + return nil +} + +func (x *GetSchemaResponse) GetSupportedHookPoints() []v1.HookPoint { + if x != nil { + return x.SupportedHookPoints + } + return nil +} + +// ConfigureResponse is empty on success. Errors surface as a gRPC status +// carrying a ToolError in its detail, per grpc.md — not an in-band field +// here. +type ConfigureResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureResponse) Reset() { + *x = ConfigureResponse{} + mi := &file_pluggableharness_tool_v1_rpc_response_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureResponse) ProtoMessage() {} + +func (x *ConfigureResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_rpc_response_proto_msgTypes[1] + 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 ConfigureResponse.ProtoReflect.Descriptor instead. +func (*ConfigureResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_rpc_response_proto_rawDescGZIP(), []int{1} +} + +// RenderResponse wraps the rendered tree. A thin per-RPC envelope around +// RenderTree, which keeps its own rich structure independent of the RPC +// signature. +type RenderResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The rendered tree. + Tree *v12.RenderTree `protobuf:"bytes,1,opt,name=tree,proto3" json:"tree,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenderResponse) Reset() { + *x = RenderResponse{} + mi := &file_pluggableharness_tool_v1_rpc_response_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenderResponse) ProtoMessage() {} + +func (x *RenderResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_rpc_response_proto_msgTypes[2] + 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 RenderResponse.ProtoReflect.Descriptor instead. +func (*RenderResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_rpc_response_proto_rawDescGZIP(), []int{2} +} + +func (x *RenderResponse) GetTree() *v12.RenderTree { + if x != nil { + return x.Tree + } + return nil +} + +// PreviewResponse carries a dry-run, human-readable description of what +// Invoke(call) would do, per protocol.md#preview — e.g. an edit tool +// returns the diff it would apply. Rendered into the plan/apply gate's +// permission UI via PlanItem.preview (pluggableharness.plan.v1, +// a sibling protocol revision) — that field and this response share the +// same pluggableharness.render.v1.RenderTree type by design, so a +// Preview call's output and a plan item's stored preview are +// interchangeable. +type PreviewResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The dry-run preview, rendered as a RenderTree. Producing this MUST NOT + // mutate anything and MUST be side-effect-free — the same guarantee + // TOOL_KIND_DATA_SOURCE operations make, but unconditionally, regardless + // of the call's actual ToolKind. + Preview *v12.RenderTree `protobuf:"bytes,1,opt,name=preview,proto3" json:"preview,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PreviewResponse) Reset() { + *x = PreviewResponse{} + mi := &file_pluggableharness_tool_v1_rpc_response_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PreviewResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PreviewResponse) ProtoMessage() {} + +func (x *PreviewResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_rpc_response_proto_msgTypes[3] + 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 PreviewResponse.ProtoReflect.Descriptor instead. +func (*PreviewResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_rpc_response_proto_rawDescGZIP(), []int{3} +} + +func (x *PreviewResponse) GetPreview() *v12.RenderTree { + if x != nil { + return x.Preview + } + return nil +} + +// DescribeResponse reports this plugin build's own identity, per +// configuration/lock-file.md's dev_overrides note: a dev_overrides-resolved +// plugin has no lock-file entry for the kernel to read {name, version, +// source, category, protocol_version} from, so the kernel obtains it +// directly from the running process via this RPC instead. +type DescribeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // This plugin build's identity. + Producer *v1.ProducerRef `protobuf:"bytes,1,opt,name=producer,proto3" json:"producer,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeResponse) Reset() { + *x = DescribeResponse{} + mi := &file_pluggableharness_tool_v1_rpc_response_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeResponse) ProtoMessage() {} + +func (x *DescribeResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_rpc_response_proto_msgTypes[4] + 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 DescribeResponse.ProtoReflect.Descriptor instead. +func (*DescribeResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_rpc_response_proto_rawDescGZIP(), []int{4} +} + +func (x *DescribeResponse) GetProducer() *v1.ProducerRef { + if x != nil { + return x.Producer + } + return nil +} + +var File_pluggableharness_tool_v1_rpc_response_proto protoreflect.FileDescriptor + +const file_pluggableharness_tool_v1_rpc_response_proto_rawDesc = "" + + "\n" + + "+pluggableharness/tool/v1/rpc_response.proto\x12\x18pluggableharness.tool.v1\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/config/v1/types.proto\x1a&pluggableharness/render/v1/types.proto\x1a$pluggableharness/tool/v1/types.proto\"\xd1\x02\n" + + "\x11GetSchemaResponse\x12:\n" + + "\x05tools\x18\x01 \x03(\v2$.pluggableharness.tool.v1.ToolSchemaR\x05tools\x12V\n" + + "\x0eslash_commands\x18\x02 \x03(\v2/.pluggableharness.common.v1.PromptExpansionSpecR\rslashCommands\x12M\n" + + "\rconfig_schema\x18\x03 \x01(\v2(.pluggableharness.config.v1.ConfigSchemaR\fconfigSchema\x12Y\n" + + "\x15supported_hook_points\x18\x04 \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPoints\"\x13\n" + + "\x11ConfigureResponse\"L\n" + + "\x0eRenderResponse\x12:\n" + + "\x04tree\x18\x01 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\x04tree\"S\n" + + "\x0fPreviewResponse\x12@\n" + + "\apreview\x18\x01 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\apreview\"W\n" + + "\x10DescribeResponse\x12C\n" + + "\bproducer\x18\x01 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\bproducerB pluggableharness.tool.v1.ToolSchema + 6, // 1: pluggableharness.tool.v1.GetSchemaResponse.slash_commands:type_name -> pluggableharness.common.v1.PromptExpansionSpec + 7, // 2: pluggableharness.tool.v1.GetSchemaResponse.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema + 8, // 3: pluggableharness.tool.v1.GetSchemaResponse.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint + 9, // 4: pluggableharness.tool.v1.RenderResponse.tree:type_name -> pluggableharness.render.v1.RenderTree + 9, // 5: pluggableharness.tool.v1.PreviewResponse.preview:type_name -> pluggableharness.render.v1.RenderTree + 10, // 6: pluggableharness.tool.v1.DescribeResponse.producer:type_name -> pluggableharness.common.v1.ProducerRef + 7, // [7:7] is the sub-list for method output_type + 7, // [7:7] 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 +} + +func init() { file_pluggableharness_tool_v1_rpc_response_proto_init() } +func file_pluggableharness_tool_v1_rpc_response_proto_init() { + if File_pluggableharness_tool_v1_rpc_response_proto != nil { + return + } + file_pluggableharness_tool_v1_types_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_tool_v1_rpc_response_proto_rawDesc), len(file_pluggableharness_tool_v1_rpc_response_proto_rawDesc)), + NumEnums: 0, + NumMessages: 5, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_tool_v1_rpc_response_proto_goTypes, + DependencyIndexes: file_pluggableharness_tool_v1_rpc_response_proto_depIdxs, + MessageInfos: file_pluggableharness_tool_v1_rpc_response_proto_msgTypes, + }.Build() + File_pluggableharness_tool_v1_rpc_response_proto = out.File + file_pluggableharness_tool_v1_rpc_response_proto_goTypes = nil + file_pluggableharness_tool_v1_rpc_response_proto_depIdxs = nil +} diff --git a/pkg/tool/proto/v1/service.pb.go b/pkg/tool/proto/v1/service.pb.go new file mode 100644 index 0000000..47108c5 --- /dev/null +++ b/pkg/tool/proto/v1/service.pb.go @@ -0,0 +1,99 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/tool/v1/service.proto + +// Package pluggableharness.tool.v1 defines the tool provider plugin protocol +// described in specifications/tool.md — the wire contract for file I/O, +// shell execution, search, web access, task tracking, sub-agent spawning, +// and similar operations. + +package toolv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_pluggableharness_tool_v1_service_proto protoreflect.FileDescriptor + +const file_pluggableharness_tool_v1_service_proto_rawDesc = "" + + "\n" + + "&pluggableharness/tool/v1/service.proto\x12\x18pluggableharness.tool.v1\x1a%pluggableharness/tool/v1/events.proto\x1a*pluggableharness/tool/v1/rpc_request.proto\x1a+pluggableharness/tool/v1/rpc_response.proto2\xd8\x04\n" + + "\vToolService\x12d\n" + + "\tGetSchema\x12*.pluggableharness.tool.v1.GetSchemaRequest\x1a+.pluggableharness.tool.v1.GetSchemaResponse\x12d\n" + + "\tConfigure\x12*.pluggableharness.tool.v1.ConfigureRequest\x1a+.pluggableharness.tool.v1.ConfigureResponse\x12]\n" + + "\x06Invoke\x12'.pluggableharness.tool.v1.InvokeRequest\x1a(.pluggableharness.tool.v1.InvokeResponse0\x01\x12[\n" + + "\x06Render\x12'.pluggableharness.tool.v1.RenderRequest\x1a(.pluggableharness.tool.v1.RenderResponse\x12^\n" + + "\aPreview\x12(.pluggableharness.tool.v1.PreviewRequest\x1a).pluggableharness.tool.v1.PreviewResponse\x12a\n" + + "\bDescribe\x12).pluggableharness.tool.v1.DescribeRequest\x1a*.pluggableharness.tool.v1.DescribeResponseB pluggableharness.tool.v1.GetSchemaRequest + 1, // 1: pluggableharness.tool.v1.ToolService.Configure:input_type -> pluggableharness.tool.v1.ConfigureRequest + 2, // 2: pluggableharness.tool.v1.ToolService.Invoke:input_type -> pluggableharness.tool.v1.InvokeRequest + 3, // 3: pluggableharness.tool.v1.ToolService.Render:input_type -> pluggableharness.tool.v1.RenderRequest + 4, // 4: pluggableharness.tool.v1.ToolService.Preview:input_type -> pluggableharness.tool.v1.PreviewRequest + 5, // 5: pluggableharness.tool.v1.ToolService.Describe:input_type -> pluggableharness.tool.v1.DescribeRequest + 6, // 6: pluggableharness.tool.v1.ToolService.GetSchema:output_type -> pluggableharness.tool.v1.GetSchemaResponse + 7, // 7: pluggableharness.tool.v1.ToolService.Configure:output_type -> pluggableharness.tool.v1.ConfigureResponse + 8, // 8: pluggableharness.tool.v1.ToolService.Invoke:output_type -> pluggableharness.tool.v1.InvokeResponse + 9, // 9: pluggableharness.tool.v1.ToolService.Render:output_type -> pluggableharness.tool.v1.RenderResponse + 10, // 10: pluggableharness.tool.v1.ToolService.Preview:output_type -> pluggableharness.tool.v1.PreviewResponse + 11, // 11: pluggableharness.tool.v1.ToolService.Describe:output_type -> pluggableharness.tool.v1.DescribeResponse + 6, // [6:12] is the sub-list for method output_type + 0, // [0:6] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_pluggableharness_tool_v1_service_proto_init() } +func file_pluggableharness_tool_v1_service_proto_init() { + if File_pluggableharness_tool_v1_service_proto != nil { + return + } + file_pluggableharness_tool_v1_events_proto_init() + file_pluggableharness_tool_v1_rpc_request_proto_init() + file_pluggableharness_tool_v1_rpc_response_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_tool_v1_service_proto_rawDesc), len(file_pluggableharness_tool_v1_service_proto_rawDesc)), + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_pluggableharness_tool_v1_service_proto_goTypes, + DependencyIndexes: file_pluggableharness_tool_v1_service_proto_depIdxs, + }.Build() + File_pluggableharness_tool_v1_service_proto = out.File + file_pluggableharness_tool_v1_service_proto_goTypes = nil + file_pluggableharness_tool_v1_service_proto_depIdxs = nil +} diff --git a/pkg/tool/proto/v1/tool_grpc.pb.go b/pkg/tool/proto/v1/service_grpc.pb.go similarity index 99% rename from pkg/tool/proto/v1/tool_grpc.pb.go rename to pkg/tool/proto/v1/service_grpc.pb.go index 0b7a8d2..762f1c9 100644 --- a/pkg/tool/proto/v1/tool_grpc.pb.go +++ b/pkg/tool/proto/v1/service_grpc.pb.go @@ -2,7 +2,7 @@ // versions: // - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) -// source: pluggableharness/tool/v1/tool.proto +// source: pluggableharness/tool/v1/service.proto // Package pluggableharness.tool.v1 defines the tool provider plugin protocol // described in specifications/tool.md — the wire contract for file I/O, @@ -380,5 +380,5 @@ var ToolService_ServiceDesc = grpc.ServiceDesc{ ServerStreams: true, }, }, - Metadata: "pluggableharness/tool/v1/tool.proto", + Metadata: "pluggableharness/tool/v1/service.proto", } diff --git a/pkg/tool/proto/v1/tool.pb.go b/pkg/tool/proto/v1/tool.pb.go deleted file mode 100644 index a5331d4..0000000 --- a/pkg/tool/proto/v1/tool.pb.go +++ /dev/null @@ -1,1974 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: pluggableharness/tool/v1/tool.proto - -// Package pluggableharness.tool.v1 defines the tool provider plugin protocol -// described in specifications/tool.md — the wire contract for file I/O, -// shell execution, search, web access, task tracking, sub-agent spawning, -// and similar operations. - -package toolv1 - -import ( - v12 "github.com/pluggableharness/agent/pkg/common/proto/v1" - v11 "github.com/pluggableharness/agent/pkg/config/proto/v1" - v14 "github.com/pluggableharness/agent/pkg/render/proto/v1" - v13 "github.com/pluggableharness/agent/pkg/schema/proto/v1" - v1 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - durationpb "google.golang.org/protobuf/types/known/durationpb" - structpb "google.golang.org/protobuf/types/known/structpb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// ToolKind is the axis that drives the plan/apply gate, per tool.md §2. -// Deliberately separate from RiskClass: kind is the binary the gate -// mechanically needs, risk is a finer-grained classification within it. -type ToolKind int32 - -const ( - // Zero value. Never valid for a real operation; its presence on the wire - // means a caller forgot to set the field. - ToolKind_TOOL_KIND_UNSPECIFIED ToolKind = 0 - // Mutating. Gated behind the plan/apply approval gate. - ToolKind_TOOL_KIND_RESOURCE ToolKind = 1 - // Read-only. Executes freely, subject only to the policy precheck - // (agent-loop.md §5.4). - ToolKind_TOOL_KIND_DATA_SOURCE ToolKind = 2 - // Blocks the current turn for human input, per tool.md §2.1. Produces no - // state mutation of its own — the human's answer becomes the result. - ToolKind_TOOL_KIND_INTERACTIVE ToolKind = 3 -) - -// Enum value maps for ToolKind. -var ( - ToolKind_name = map[int32]string{ - 0: "TOOL_KIND_UNSPECIFIED", - 1: "TOOL_KIND_RESOURCE", - 2: "TOOL_KIND_DATA_SOURCE", - 3: "TOOL_KIND_INTERACTIVE", - } - ToolKind_value = map[string]int32{ - "TOOL_KIND_UNSPECIFIED": 0, - "TOOL_KIND_RESOURCE": 1, - "TOOL_KIND_DATA_SOURCE": 2, - "TOOL_KIND_INTERACTIVE": 3, - } -) - -func (x ToolKind) Enum() *ToolKind { - p := new(ToolKind) - *p = x - return p -} - -func (x ToolKind) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (ToolKind) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_tool_v1_tool_proto_enumTypes[0].Descriptor() -} - -func (ToolKind) Type() protoreflect.EnumType { - return &file_pluggableharness_tool_v1_tool_proto_enumTypes[0] -} - -func (x ToolKind) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use ToolKind.Descriptor instead. -func (ToolKind) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{0} -} - -// RiskClass classifies an operation's blast radius, per tool.md §2. Orthogonal -// to ToolKind: kind determines whether the plan/apply gate applies at all, -// risk determines how significant the gated (or inherently ungated) action is. -type RiskClass int32 - -const ( - // Zero value. Never valid for a real operation; its presence on the wire - // means a caller forgot to set the field. - RiskClass_RISK_CLASS_UNSPECIFIED RiskClass = 0 - // Inherently unable to mutate anything the plugin controls. MUST be used - // for TOOL_KIND_DATA_SOURCE and TOOL_KIND_INTERACTIVE alike — neither - // mutates nor reads anything external, so neither has a blast radius to - // classify. - RiskClass_RISK_CLASS_READ_ONLY RiskClass = 1 - // A resource operation with narrow, easily-reversible blast radius, e.g. - // a write to a scratch path. - RiskClass_RISK_CLASS_LOW RiskClass = 2 - // A resource operation with real but bounded blast radius, e.g. editing - // a tracked source file. - RiskClass_RISK_CLASS_MODERATE RiskClass = 3 - // A resource operation with broad or hard-to-predict blast radius, e.g. - // arbitrary shell execution. - RiskClass_RISK_CLASS_HIGH RiskClass = 4 - // A resource operation capable of irreversible or wide-blast-radius - // action, e.g. `rm -rf`, a force-push, or spawning a sub-agent with - // further unattended write access. - // - // A TOOL_KIND_RESOURCE operation MUST declare one of LOW/MODERATE/HIGH/ - // CRITICAL — never READ_ONLY. There is no resource with read_only risk. - RiskClass_RISK_CLASS_CRITICAL RiskClass = 5 -) - -// Enum value maps for RiskClass. -var ( - RiskClass_name = map[int32]string{ - 0: "RISK_CLASS_UNSPECIFIED", - 1: "RISK_CLASS_READ_ONLY", - 2: "RISK_CLASS_LOW", - 3: "RISK_CLASS_MODERATE", - 4: "RISK_CLASS_HIGH", - 5: "RISK_CLASS_CRITICAL", - } - RiskClass_value = map[string]int32{ - "RISK_CLASS_UNSPECIFIED": 0, - "RISK_CLASS_READ_ONLY": 1, - "RISK_CLASS_LOW": 2, - "RISK_CLASS_MODERATE": 3, - "RISK_CLASS_HIGH": 4, - "RISK_CLASS_CRITICAL": 5, - } -) - -func (x RiskClass) Enum() *RiskClass { - p := new(RiskClass) - *p = x - return p -} - -func (x RiskClass) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (RiskClass) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_tool_v1_tool_proto_enumTypes[1].Descriptor() -} - -func (RiskClass) Type() protoreflect.EnumType { - return &file_pluggableharness_tool_v1_tool_proto_enumTypes[1] -} - -func (x RiskClass) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use RiskClass.Descriptor instead. -func (RiskClass) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{1} -} - -// OutputStream distinguishes which underlying stream an OutputChunk came -// from. -type OutputStream int32 - -const ( - // Zero value. Never valid for a real chunk; its presence on the wire - // means a caller forgot to set the field. - OutputStream_OUTPUT_STREAM_UNSPECIFIED OutputStream = 0 - // Standard output. - OutputStream_OUTPUT_STREAM_STDOUT OutputStream = 1 - // Standard error. - OutputStream_OUTPUT_STREAM_STDERR OutputStream = 2 -) - -// Enum value maps for OutputStream. -var ( - OutputStream_name = map[int32]string{ - 0: "OUTPUT_STREAM_UNSPECIFIED", - 1: "OUTPUT_STREAM_STDOUT", - 2: "OUTPUT_STREAM_STDERR", - } - OutputStream_value = map[string]int32{ - "OUTPUT_STREAM_UNSPECIFIED": 0, - "OUTPUT_STREAM_STDOUT": 1, - "OUTPUT_STREAM_STDERR": 2, - } -) - -func (x OutputStream) Enum() *OutputStream { - p := new(OutputStream) - *p = x - return p -} - -func (x OutputStream) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (OutputStream) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_tool_v1_tool_proto_enumTypes[2].Descriptor() -} - -func (OutputStream) Type() protoreflect.EnumType { - return &file_pluggableharness_tool_v1_tool_proto_enumTypes[2] -} - -func (x OutputStream) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use OutputStream.Descriptor instead. -func (OutputStream) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{2} -} - -// ToolErrorCategory classifies why an Invoke call failed, per tool.md §8. -// Deliberately distinct from model.md §8's ModelErrorCategory — there -// is no RATE_LIMITED or CONTEXT_LENGTH_EXCEEDED here, those are -// model-vendor concepts. The two enums MUST NOT be merged even though the -// surrounding error-envelope shape (category/message/retryable) is -// parallel. -type ToolErrorCategory int32 - -const ( - // Zero value. Never valid for a real error; its presence on the wire - // means a caller forgot to set the field. - ToolErrorCategory_TOOL_ERROR_CATEGORY_UNSPECIFIED ToolErrorCategory = 0 - // Input failed input_schema validation. - ToolErrorCategory_TOOL_ERROR_CATEGORY_INVALID_ARGUMENTS ToolErrorCategory = 1 - // The target of the operation doesn't exist (path, URL, symbol, ...). - ToolErrorCategory_TOOL_ERROR_CATEGORY_NOT_FOUND ToolErrorCategory = 2 - // OS/policy denied the underlying operation. - ToolErrorCategory_TOOL_ERROR_CATEGORY_PERMISSION_DENIED ToolErrorCategory = 3 - // The operation ran but failed on its own terms (non-zero exit, compiler - // error, HTTP 4xx/5xx) — not a plugin bug. - ToolErrorCategory_TOOL_ERROR_CATEGORY_EXECUTION_FAILED ToolErrorCategory = 4 - // Exceeded a plugin- or kernel-enforced deadline. - ToolErrorCategory_TOOL_ERROR_CATEGORY_TIMEOUT ToolErrorCategory = 5 - // The provider detected a conflicting concurrent call it could not - // serialize itself (see ConcurrencySpec) — signals the kernel to retry - // serialized. - ToolErrorCategory_TOOL_ERROR_CATEGORY_CONCURRENCY_CONFLICT ToolErrorCategory = 6 - // The stream was cancelled per tool.md §1 — not "an error" in the - // failure sense; kept distinct so the kernel doesn't surface it to the - // model as a tool failure when the whole turn is being aborted anyway. - ToolErrorCategory_TOOL_ERROR_CATEGORY_CANCELLED ToolErrorCategory = 7 - // The plugin subprocess itself died mid-Invoke (transport error, not a - // graceful error event the plugin chose to emit). MUST be - // kernel-synthesized only — a plugin process that crashes obviously - // cannot emit this itself; the kernel detects the crash and synthesizes - // this category. - ToolErrorCategory_TOOL_ERROR_CATEGORY_PROCESS_CRASHED ToolErrorCategory = 8 - // Anything else. MUST include the raw underlying error in - // ToolError.details. - ToolErrorCategory_TOOL_ERROR_CATEGORY_UNKNOWN ToolErrorCategory = 9 -) - -// Enum value maps for ToolErrorCategory. -var ( - ToolErrorCategory_name = map[int32]string{ - 0: "TOOL_ERROR_CATEGORY_UNSPECIFIED", - 1: "TOOL_ERROR_CATEGORY_INVALID_ARGUMENTS", - 2: "TOOL_ERROR_CATEGORY_NOT_FOUND", - 3: "TOOL_ERROR_CATEGORY_PERMISSION_DENIED", - 4: "TOOL_ERROR_CATEGORY_EXECUTION_FAILED", - 5: "TOOL_ERROR_CATEGORY_TIMEOUT", - 6: "TOOL_ERROR_CATEGORY_CONCURRENCY_CONFLICT", - 7: "TOOL_ERROR_CATEGORY_CANCELLED", - 8: "TOOL_ERROR_CATEGORY_PROCESS_CRASHED", - 9: "TOOL_ERROR_CATEGORY_UNKNOWN", - } - ToolErrorCategory_value = map[string]int32{ - "TOOL_ERROR_CATEGORY_UNSPECIFIED": 0, - "TOOL_ERROR_CATEGORY_INVALID_ARGUMENTS": 1, - "TOOL_ERROR_CATEGORY_NOT_FOUND": 2, - "TOOL_ERROR_CATEGORY_PERMISSION_DENIED": 3, - "TOOL_ERROR_CATEGORY_EXECUTION_FAILED": 4, - "TOOL_ERROR_CATEGORY_TIMEOUT": 5, - "TOOL_ERROR_CATEGORY_CONCURRENCY_CONFLICT": 6, - "TOOL_ERROR_CATEGORY_CANCELLED": 7, - "TOOL_ERROR_CATEGORY_PROCESS_CRASHED": 8, - "TOOL_ERROR_CATEGORY_UNKNOWN": 9, - } -) - -func (x ToolErrorCategory) Enum() *ToolErrorCategory { - p := new(ToolErrorCategory) - *p = x - return p -} - -func (x ToolErrorCategory) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (ToolErrorCategory) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_tool_v1_tool_proto_enumTypes[3].Descriptor() -} - -func (ToolErrorCategory) Type() protoreflect.EnumType { - return &file_pluggableharness_tool_v1_tool_proto_enumTypes[3] -} - -func (x ToolErrorCategory) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use ToolErrorCategory.Descriptor instead. -func (ToolErrorCategory) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{3} -} - -// GetSchemaRequest carries no fields — GetSchema takes no parameters. -type GetSchemaRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetSchemaRequest) Reset() { - *x = GetSchemaRequest{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetSchemaRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetSchemaRequest) ProtoMessage() {} - -func (x *GetSchemaRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[0] - 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 GetSchemaRequest.ProtoReflect.Descriptor instead. -func (*GetSchemaRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{0} -} - -// GetSchemaResponse is this provider's complete capability advertisement. -type GetSchemaResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // One entry per operation this plugin exposes. - Tools []*ToolSchema `protobuf:"bytes,1,rep,name=tools,proto3" json:"tools,omitempty"` - // Slash commands this provider contributes, per tool.md §2.1. MAY be - // empty. Each entry's tool_name MUST reference one of this same - // provider's own operations declared in `tools` above — a provider - // cannot declare a slash command that invokes another provider's tool. - SlashCommands []*v1.SlashCommandSpec `protobuf:"bytes,2,rep,name=slash_commands,json=slashCommands,proto3" json:"slash_commands,omitempty"` - // This provider's agent.hcl config schema, per configuration.md §4 — - // what fields Configure's request may be decoded from. - ConfigSchema *v11.ConfigSchema `protobuf:"bytes,3,opt,name=config_schema,json=configSchema,proto3" json:"config_schema,omitempty"` - // Which of the eight dispatchable hook points (hook/v1.HookPoint) this - // provider subscribes a HookSubscriberService to, per this provider's own - // agent.hcl hook{} blocks. Lets the kernel validate a hook{} declaration - // at config-load time instead of discovering an unsupported subscription - // only when that hook point first fires. - SupportedHookPoints []v12.HookPoint `protobuf:"varint,4,rep,packed,name=supported_hook_points,json=supportedHookPoints,proto3,enum=pluggableharness.common.v1.HookPoint" json:"supported_hook_points,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetSchemaResponse) Reset() { - *x = GetSchemaResponse{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetSchemaResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetSchemaResponse) ProtoMessage() {} - -func (x *GetSchemaResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[1] - 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 GetSchemaResponse.ProtoReflect.Descriptor instead. -func (*GetSchemaResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{1} -} - -func (x *GetSchemaResponse) GetTools() []*ToolSchema { - if x != nil { - return x.Tools - } - return nil -} - -func (x *GetSchemaResponse) GetSlashCommands() []*v1.SlashCommandSpec { - if x != nil { - return x.SlashCommands - } - return nil -} - -func (x *GetSchemaResponse) GetConfigSchema() *v11.ConfigSchema { - if x != nil { - return x.ConfigSchema - } - return nil -} - -func (x *GetSchemaResponse) GetSupportedHookPoints() []v12.HookPoint { - if x != nil { - return x.SupportedHookPoints - } - return nil -} - -// ConfigureRequest wraps this provider's already-decoded agent.hcl config. -type ConfigureRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The provider-specific config, decoded from agent.hcl via the - // schema-to-cty bridge before crossing the wire. - Config *structpb.Struct `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ConfigureRequest) Reset() { - *x = ConfigureRequest{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ConfigureRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConfigureRequest) ProtoMessage() {} - -func (x *ConfigureRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[2] - 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 ConfigureRequest.ProtoReflect.Descriptor instead. -func (*ConfigureRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{2} -} - -func (x *ConfigureRequest) GetConfig() *structpb.Struct { - if x != nil { - return x.Config - } - return nil -} - -// ConfigureResponse is empty on success. Errors surface as a gRPC status -// carrying a ToolError in its detail, per grpc.md — not an in-band field -// here. -type ConfigureResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ConfigureResponse) Reset() { - *x = ConfigureResponse{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ConfigureResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConfigureResponse) ProtoMessage() {} - -func (x *ConfigureResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[3] - 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 ConfigureResponse.ProtoReflect.Descriptor instead. -func (*ConfigureResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{3} -} - -// ConcurrencySpec declares whether this operation's Invoke calls may run -// concurrently against the same provider process, per tool.md §5. -type ConcurrencySpec struct { - state protoimpl.MessageState `protogen:"open.v1"` - // MUST be set, per operation, except for TOOL_KIND_INTERACTIVE. false (or - // an absent/unset default) means the kernel MUST NOT run any other Invoke - // call against this provider process concurrently with this one — a - // coarse, provider-wide lock. true means concurrent Invoke calls against - // this provider are generally safe. A provider that does not populate - // this field at all MUST be treated by the kernel as false — the - // conservative default. - Safe bool `protobuf:"varint,1,opt,name=safe,proto3" json:"safe,omitempty"` - // Only meaningful when safe == true. Names of this operation's - // input_schema fields whose value(s) form a serialization key. The - // kernel computes key = (provider_name, tool_name, value(key_fields)) and - // MUST serialize calls sharing an identical key, while still freely - // parallelizing calls with distinct keys. Omitting key_fields under - // safe == true asserts that no two calls to this operation can ever - // conflict — a strong claim, true for e.g. web_search, false for e.g. - // write_file. - KeyFields []string `protobuf:"bytes,2,rep,name=key_fields,json=keyFields,proto3" json:"key_fields,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ConcurrencySpec) Reset() { - *x = ConcurrencySpec{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ConcurrencySpec) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConcurrencySpec) ProtoMessage() {} - -func (x *ConcurrencySpec) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[4] - 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 ConcurrencySpec.ProtoReflect.Descriptor instead. -func (*ConcurrencySpec) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{4} -} - -func (x *ConcurrencySpec) GetSafe() bool { - if x != nil { - return x.Safe - } - return false -} - -func (x *ConcurrencySpec) GetKeyFields() []string { - if x != nil { - return x.KeyFields - } - return nil -} - -// ToolSchema declares one operation this provider exposes, per tool.md §2. -type ToolSchema struct { - state protoimpl.MessageState `protogen:"open.v1"` - // MUST — unique within this provider's namespace, e.g. "read_file". - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - // MUST — drives the plan/apply gate. - Kind ToolKind `protobuf:"varint,2,opt,name=kind,proto3,enum=pluggableharness.tool.v1.ToolKind" json:"kind,omitempty"` - // MUST — see RiskClass. - Risk RiskClass `protobuf:"varint,3,opt,name=risk,proto3,enum=pluggableharness.tool.v1.RiskClass" json:"risk,omitempty"` - // MUST — shown to the model for tool selection and in plan diffs. - Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` - // MUST — the common JSON-Schema subset per model.md §6, describing the - // shape of ToolCall.arguments for this operation. - InputSchema *v13.Schema `protobuf:"bytes,5,opt,name=input_schema,json=inputSchema,proto3" json:"input_schema,omitempty"` - // MUST — the common JSON-Schema subset per model.md §6, describing the - // shape of ToolResult.payload for this operation. - OutputSchema *v13.Schema `protobuf:"bytes,6,opt,name=output_schema,json=outputSchema,proto3" json:"output_schema,omitempty"` - // MUST — true if Invoke may emit intermediate ToolEvents (output_chunk, - // progress, partial_result) before the terminal event; false if Invoke - // always emits exactly one terminal event with no lead-up. - Streaming bool `protobuf:"varint,7,opt,name=streaming,proto3" json:"streaming,omitempty"` - // MUST, except MUST NOT be meaningfully set for TOOL_KIND_INTERACTIVE. - Concurrency *ConcurrencySpec `protobuf:"bytes,8,opt,name=concurrency,proto3" json:"concurrency,omitempty"` - // SHOULD — the deadline the kernel applies to Invoke for this operation - // absent an agent.hcl override. Absent means the kernel's global default - // applies instead (configuration/settings-and-global.md). - DefaultTimeout *durationpb.Duration `protobuf:"bytes,9,opt,name=default_timeout,json=defaultTimeout,proto3,oneof" json:"default_timeout,omitempty"` - // True iff re-running this operation with identical arguments cannot - // produce a different end state than running it once. Gates whether the - // kernel MAY auto-retry a retryable ToolError for a TOOL_KIND_RESOURCE - // operation — see conformance.md#error-taxonomy's retry interaction. - // TOOL_KIND_DATA_SOURCE operations are implicitly safe to retry - // regardless of this field. - Idempotent bool `protobuf:"varint,10,opt,name=idempotent,proto3" json:"idempotent,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ToolSchema) Reset() { - *x = ToolSchema{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ToolSchema) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ToolSchema) ProtoMessage() {} - -func (x *ToolSchema) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_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 ToolSchema.ProtoReflect.Descriptor instead. -func (*ToolSchema) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{5} -} - -func (x *ToolSchema) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *ToolSchema) GetKind() ToolKind { - if x != nil { - return x.Kind - } - return ToolKind_TOOL_KIND_UNSPECIFIED -} - -func (x *ToolSchema) GetRisk() RiskClass { - if x != nil { - return x.Risk - } - return RiskClass_RISK_CLASS_UNSPECIFIED -} - -func (x *ToolSchema) GetDescription() string { - if x != nil { - return x.Description - } - return "" -} - -func (x *ToolSchema) GetInputSchema() *v13.Schema { - if x != nil { - return x.InputSchema - } - return nil -} - -func (x *ToolSchema) GetOutputSchema() *v13.Schema { - if x != nil { - return x.OutputSchema - } - return nil -} - -func (x *ToolSchema) GetStreaming() bool { - if x != nil { - return x.Streaming - } - return false -} - -func (x *ToolSchema) GetConcurrency() *ConcurrencySpec { - if x != nil { - return x.Concurrency - } - return nil -} - -func (x *ToolSchema) GetDefaultTimeout() *durationpb.Duration { - if x != nil { - return x.DefaultTimeout - } - return nil -} - -func (x *ToolSchema) GetIdempotent() bool { - if x != nil { - return x.Idempotent - } - return false -} - -// InvokeRequest wraps the call to execute. A thin per-RPC envelope around -// ToolCall, which keeps its own rich structure independent of the RPC -// signature. -type InvokeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The call to execute. - Call *ToolCall `protobuf:"bytes,1,opt,name=call,proto3" json:"call,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *InvokeRequest) Reset() { - *x = InvokeRequest{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *InvokeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*InvokeRequest) ProtoMessage() {} - -func (x *InvokeRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_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 InvokeRequest.ProtoReflect.Descriptor instead. -func (*InvokeRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{6} -} - -func (x *InvokeRequest) GetCall() *ToolCall { - if x != nil { - return x.Call - } - return nil -} - -// InvokeResponse wraps one message in the stream Invoke returns. A thin -// per-RPC envelope around ToolEvent, which keeps its own rich structure -// independent of the RPC signature. -type InvokeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The event. - Event *ToolEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *InvokeResponse) Reset() { - *x = InvokeResponse{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *InvokeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*InvokeResponse) ProtoMessage() {} - -func (x *InvokeResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[7] - 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 InvokeResponse.ProtoReflect.Descriptor instead. -func (*InvokeResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{7} -} - -func (x *InvokeResponse) GetEvent() *ToolEvent { - if x != nil { - return x.Event - } - return nil -} - -// ToolCall is one request to execute an operation, per tool.md §4. -type ToolCall struct { - state protoimpl.MessageState `protogen:"open.v1"` - // MUST — kernel-assigned. Echoed in every ToolEvent for this call, for - // correlation. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - // MUST — matches a ToolSchema.name from this provider's GetSchema - // response. - ToolName string `protobuf:"bytes,2,opt,name=tool_name,json=toolName,proto3" json:"tool_name,omitempty"` - // MUST — already-parsed JSON conforming to that ToolSchema's - // input_schema. - Arguments *structpb.Struct `protobuf:"bytes,3,opt,name=arguments,proto3" json:"arguments,omitempty"` - // MUST be set by the kernel. Carries the session_id/turn_id this call - // executes for — what the plugin echoes back on its own - // KernelCallbackService.Emit/Log calls for attribution — and the - // session's working_directory, which any process-backed operation - // (the reference catalog's exec.bash, read_file, and similar) MUST - // resolve relative-path arguments against. See - // pluggableharness.common.v1.CallContext. - CallContext *v12.CallContext `protobuf:"bytes,4,opt,name=call_context,json=callContext,proto3" json:"call_context,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ToolCall) Reset() { - *x = ToolCall{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ToolCall) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ToolCall) ProtoMessage() {} - -func (x *ToolCall) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[8] - 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 ToolCall.ProtoReflect.Descriptor instead. -func (*ToolCall) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{8} -} - -func (x *ToolCall) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *ToolCall) GetToolName() string { - if x != nil { - return x.ToolName - } - return "" -} - -func (x *ToolCall) GetArguments() *structpb.Struct { - if x != nil { - return x.Arguments - } - return nil -} - -func (x *ToolCall) GetCallContext() *v12.CallContext { - if x != nil { - return x.CallContext - } - return nil -} - -// ToolEvent is one message in the stream Invoke returns, per tool.md §4. -// Exactly one of `result`/`error` closes the stream; `output_chunk`, -// `progress`, and `partial_result` MAY each appear zero or more times -// before it; `exit_status` MAY appear at most once. The relative order of -// `output_chunk` events MUST be preserved by the transport. -type ToolEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Types that are valid to be assigned to Event: - // - // *ToolEvent_OutputChunk_ - // *ToolEvent_Progress_ - // *ToolEvent_PartialResult_ - // *ToolEvent_ExitStatus_ - // *ToolEvent_Result - // *ToolEvent_Error - Event isToolEvent_Event `protobuf_oneof:"event"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ToolEvent) Reset() { - *x = ToolEvent{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ToolEvent) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ToolEvent) ProtoMessage() {} - -func (x *ToolEvent) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[9] - 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 ToolEvent.ProtoReflect.Descriptor instead. -func (*ToolEvent) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{9} -} - -func (x *ToolEvent) GetEvent() isToolEvent_Event { - if x != nil { - return x.Event - } - return nil -} - -func (x *ToolEvent) GetOutputChunk() *ToolEvent_OutputChunk { - if x != nil { - if x, ok := x.Event.(*ToolEvent_OutputChunk_); ok { - return x.OutputChunk - } - } - return nil -} - -func (x *ToolEvent) GetProgress() *ToolEvent_Progress { - if x != nil { - if x, ok := x.Event.(*ToolEvent_Progress_); ok { - return x.Progress - } - } - return nil -} - -func (x *ToolEvent) GetPartialResult() *ToolEvent_PartialResult { - if x != nil { - if x, ok := x.Event.(*ToolEvent_PartialResult_); ok { - return x.PartialResult - } - } - return nil -} - -func (x *ToolEvent) GetExitStatus() *ToolEvent_ExitStatus { - if x != nil { - if x, ok := x.Event.(*ToolEvent_ExitStatus_); ok { - return x.ExitStatus - } - } - return nil -} - -func (x *ToolEvent) GetResult() *ToolResult { - if x != nil { - if x, ok := x.Event.(*ToolEvent_Result); ok { - return x.Result - } - } - return nil -} - -func (x *ToolEvent) GetError() *ToolError { - if x != nil { - if x, ok := x.Event.(*ToolEvent_Error); ok { - return x.Error - } - } - return nil -} - -type isToolEvent_Event interface { - isToolEvent_Event() -} - -type ToolEvent_OutputChunk_ struct { - // Incremental raw output from a process-backed operation. - OutputChunk *ToolEvent_OutputChunk `protobuf:"bytes,1,opt,name=output_chunk,json=outputChunk,proto3,oneof"` -} - -type ToolEvent_Progress_ struct { - // A human-readable progress update. - Progress *ToolEvent_Progress `protobuf:"bytes,2,opt,name=progress,proto3,oneof"` -} - -type ToolEvent_PartialResult_ struct { - // Incremental structured output, e.g. search hits as they're found. - PartialResult *ToolEvent_PartialResult `protobuf:"bytes,3,opt,name=partial_result,json=partialResult,proto3,oneof"` -} - -type ToolEvent_ExitStatus_ struct { - // The exit status of a process-backed operation's child process. - ExitStatus *ToolEvent_ExitStatus `protobuf:"bytes,4,opt,name=exit_status,json=exitStatus,proto3,oneof"` -} - -type ToolEvent_Result struct { - // The terminal, successful result of this call. - Result *ToolResult `protobuf:"bytes,5,opt,name=result,proto3,oneof"` -} - -type ToolEvent_Error struct { - // The terminal, failed result of this call. - Error *ToolError `protobuf:"bytes,6,opt,name=error,proto3,oneof"` -} - -func (*ToolEvent_OutputChunk_) isToolEvent_Event() {} - -func (*ToolEvent_Progress_) isToolEvent_Event() {} - -func (*ToolEvent_PartialResult_) isToolEvent_Event() {} - -func (*ToolEvent_ExitStatus_) isToolEvent_Event() {} - -func (*ToolEvent_Result) isToolEvent_Event() {} - -func (*ToolEvent_Error) isToolEvent_Event() {} - -// ToolResult is the terminal, successful outcome of an Invoke call. -type ToolResult struct { - state protoimpl.MessageState `protogen:"open.v1"` - // MUST conform to the ToolSchema.output_schema declared for this call's - // tool_name; the kernel strictly validates this — a non-conforming - // payload becomes a ToolError with category TOOL_ERROR_CATEGORY_UNKNOWN - // rather than being passed through to history. - Payload *structpb.Struct `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ToolResult) Reset() { - *x = ToolResult{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ToolResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ToolResult) ProtoMessage() {} - -func (x *ToolResult) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[10] - 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 ToolResult.ProtoReflect.Descriptor instead. -func (*ToolResult) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{10} -} - -func (x *ToolResult) GetPayload() *structpb.Struct { - if x != nil { - return x.Payload - } - return nil -} - -// ToolError is the terminal, failed outcome of an Invoke call. -type ToolError struct { - state protoimpl.MessageState `protogen:"open.v1"` - // MUST. - Category ToolErrorCategory `protobuf:"varint,1,opt,name=category,proto3,enum=pluggableharness.tool.v1.ToolErrorCategory" json:"category,omitempty"` - // MUST — human-readable. - Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` - // MUST. - Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"` - // MAY — provider-specific structured detail. MUST include the raw - // underlying error for category TOOL_ERROR_CATEGORY_UNKNOWN. - Details *structpb.Struct `protobuf:"bytes,4,opt,name=details,proto3,oneof" json:"details,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ToolError) Reset() { - *x = ToolError{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ToolError) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ToolError) ProtoMessage() {} - -func (x *ToolError) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[11] - 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 ToolError.ProtoReflect.Descriptor instead. -func (*ToolError) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{11} -} - -func (x *ToolError) GetCategory() ToolErrorCategory { - if x != nil { - return x.Category - } - return ToolErrorCategory_TOOL_ERROR_CATEGORY_UNSPECIFIED -} - -func (x *ToolError) GetMessage() string { - if x != nil { - return x.Message - } - return "" -} - -func (x *ToolError) GetRetryable() bool { - if x != nil { - return x.Retryable - } - return false -} - -func (x *ToolError) GetDetails() *structpb.Struct { - if x != nil { - return x.Details - } - return nil -} - -// RenderRequest carries the opaque payload to render, per tool.md §7. See -// grpc.md's Emit->Render->Paint carve-out for why this field stays `bytes` -// rather than a strongly-typed message. -type RenderRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The opaque emitted payload to render. - Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` - // The schema version the payload was emitted under, per - // ../frontend/render-tree.md#schema-versioning. Lets a Render - // implementation decode a payload emitted by an older build of this - // same plugin. - SchemaVersion string `protobuf:"bytes,2,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RenderRequest) Reset() { - *x = RenderRequest{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RenderRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RenderRequest) ProtoMessage() {} - -func (x *RenderRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[12] - 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 RenderRequest.ProtoReflect.Descriptor instead. -func (*RenderRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{12} -} - -func (x *RenderRequest) GetPayload() []byte { - if x != nil { - return x.Payload - } - return nil -} - -func (x *RenderRequest) GetSchemaVersion() string { - if x != nil { - return x.SchemaVersion - } - return "" -} - -// RenderResponse wraps the rendered tree. A thin per-RPC envelope around -// RenderTree, which keeps its own rich structure independent of the RPC -// signature. -type RenderResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The rendered tree. - Tree *v14.RenderTree `protobuf:"bytes,1,opt,name=tree,proto3" json:"tree,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RenderResponse) Reset() { - *x = RenderResponse{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RenderResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RenderResponse) ProtoMessage() {} - -func (x *RenderResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[13] - 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 RenderResponse.ProtoReflect.Descriptor instead. -func (*RenderResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{13} -} - -func (x *RenderResponse) GetTree() *v14.RenderTree { - if x != nil { - return x.Tree - } - return nil -} - -// PreviewRequest wraps the call to describe, per protocol.md#preview. -type PreviewRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The call Preview describes a dry run of. Same shape as an Invoke - // request; Preview MUST NOT execute it. - Call *ToolCall `protobuf:"bytes,1,opt,name=call,proto3" json:"call,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PreviewRequest) Reset() { - *x = PreviewRequest{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PreviewRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PreviewRequest) ProtoMessage() {} - -func (x *PreviewRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[14] - 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 PreviewRequest.ProtoReflect.Descriptor instead. -func (*PreviewRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{14} -} - -func (x *PreviewRequest) GetCall() *ToolCall { - if x != nil { - return x.Call - } - return nil -} - -// PreviewResponse carries a dry-run, human-readable description of what -// Invoke(call) would do, per protocol.md#preview — e.g. an edit tool -// returns the diff it would apply. Rendered into the plan/apply gate's -// permission UI via PlanItem.preview (pluggableharness.plan.v1, -// a sibling protocol revision) — that field and this response share the -// same pluggableharness.render.v1.RenderTree type by design, so a -// Preview call's output and a plan item's stored preview are -// interchangeable. -type PreviewResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The dry-run preview, rendered as a RenderTree. Producing this MUST NOT - // mutate anything and MUST be side-effect-free — the same guarantee - // TOOL_KIND_DATA_SOURCE operations make, but unconditionally, regardless - // of the call's actual ToolKind. - Preview *v14.RenderTree `protobuf:"bytes,1,opt,name=preview,proto3" json:"preview,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PreviewResponse) Reset() { - *x = PreviewResponse{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PreviewResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PreviewResponse) ProtoMessage() {} - -func (x *PreviewResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_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 PreviewResponse.ProtoReflect.Descriptor instead. -func (*PreviewResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{15} -} - -func (x *PreviewResponse) GetPreview() *v14.RenderTree { - if x != nil { - return x.Preview - } - return nil -} - -// DescribeRequest carries no fields — Describe takes no parameters. -type DescribeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DescribeRequest) Reset() { - *x = DescribeRequest{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DescribeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DescribeRequest) ProtoMessage() {} - -func (x *DescribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_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 DescribeRequest.ProtoReflect.Descriptor instead. -func (*DescribeRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{16} -} - -// DescribeResponse reports this plugin build's own identity, per -// configuration/lock-file.md's dev_overrides note: a dev_overrides-resolved -// plugin has no lock-file entry for the kernel to read {name, version, -// source, category, protocol_version} from, so the kernel obtains it -// directly from the running process via this RPC instead. -type DescribeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // This plugin build's identity. - Producer *v12.ProducerRef `protobuf:"bytes,1,opt,name=producer,proto3" json:"producer,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DescribeResponse) Reset() { - *x = DescribeResponse{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[17] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DescribeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DescribeResponse) ProtoMessage() {} - -func (x *DescribeResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[17] - 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 DescribeResponse.ProtoReflect.Descriptor instead. -func (*DescribeResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{17} -} - -func (x *DescribeResponse) GetProducer() *v12.ProducerRef { - if x != nil { - return x.Producer - } - return nil -} - -// OutputChunk carries one slice of raw stdout/stderr-shaped output from a -// process-backed operation. -type ToolEvent_OutputChunk struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Which stream this chunk came from. - Stream OutputStream `protobuf:"varint,1,opt,name=stream,proto3,enum=pluggableharness.tool.v1.OutputStream" json:"stream,omitempty"` - // The chunk's raw bytes. - Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ToolEvent_OutputChunk) Reset() { - *x = ToolEvent_OutputChunk{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[18] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ToolEvent_OutputChunk) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ToolEvent_OutputChunk) ProtoMessage() {} - -func (x *ToolEvent_OutputChunk) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[18] - 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 ToolEvent_OutputChunk.ProtoReflect.Descriptor instead. -func (*ToolEvent_OutputChunk) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{9, 0} -} - -func (x *ToolEvent_OutputChunk) GetStream() OutputStream { - if x != nil { - return x.Stream - } - return OutputStream_OUTPUT_STREAM_UNSPECIFIED -} - -func (x *ToolEvent_OutputChunk) GetData() []byte { - if x != nil { - return x.Data - } - return nil -} - -// Progress carries a human-readable status update for a long-running -// call. -type ToolEvent_Progress struct { - state protoimpl.MessageState `protogen:"open.v1"` - // A human-readable description of the current step. - Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` - // How far through the operation this call is, in [0.0, 1.0]. Absent - // means the provider cannot estimate completion fraction. - FractionComplete *float64 `protobuf:"fixed64,2,opt,name=fraction_complete,json=fractionComplete,proto3,oneof" json:"fraction_complete,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ToolEvent_Progress) Reset() { - *x = ToolEvent_Progress{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[19] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ToolEvent_Progress) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ToolEvent_Progress) ProtoMessage() {} - -func (x *ToolEvent_Progress) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[19] - 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 ToolEvent_Progress.ProtoReflect.Descriptor instead. -func (*ToolEvent_Progress) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{9, 1} -} - -func (x *ToolEvent_Progress) GetMessage() string { - if x != nil { - return x.Message - } - return "" -} - -func (x *ToolEvent_Progress) GetFractionComplete() float64 { - if x != nil && x.FractionComplete != nil { - return *x.FractionComplete - } - return 0 -} - -// PartialResult carries incremental structured output before the -// terminal result, e.g. search hits as they're found. -type ToolEvent_PartialResult struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The incremental structured payload. - Payload *structpb.Struct `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ToolEvent_PartialResult) Reset() { - *x = ToolEvent_PartialResult{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[20] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ToolEvent_PartialResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ToolEvent_PartialResult) ProtoMessage() {} - -func (x *ToolEvent_PartialResult) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[20] - 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 ToolEvent_PartialResult.ProtoReflect.Descriptor instead. -func (*ToolEvent_PartialResult) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{9, 2} -} - -func (x *ToolEvent_PartialResult) GetPayload() *structpb.Struct { - if x != nil { - return x.Payload - } - return nil -} - -// ExitStatus carries a process-backed operation's child process exit -// information. exec-family tools only — a provider for a non-process- -// backed tool (file read, grep, web fetch) MUST NOT emit this. Appears -// at most once per Invoke stream. -type ToolEvent_ExitStatus struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The child process's exit code. - ExitCode int32 `protobuf:"varint,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` - // The signal that terminated the child process, if any. Absent means - // the process exited normally (exit_code is meaningful on its own). - Signal *string `protobuf:"bytes,2,opt,name=signal,proto3,oneof" json:"signal,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ToolEvent_ExitStatus) Reset() { - *x = ToolEvent_ExitStatus{} - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[21] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ToolEvent_ExitStatus) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ToolEvent_ExitStatus) ProtoMessage() {} - -func (x *ToolEvent_ExitStatus) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_tool_v1_tool_proto_msgTypes[21] - 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 ToolEvent_ExitStatus.ProtoReflect.Descriptor instead. -func (*ToolEvent_ExitStatus) Descriptor() ([]byte, []int) { - return file_pluggableharness_tool_v1_tool_proto_rawDescGZIP(), []int{9, 3} -} - -func (x *ToolEvent_ExitStatus) GetExitCode() int32 { - if x != nil { - return x.ExitCode - } - return 0 -} - -func (x *ToolEvent_ExitStatus) GetSignal() string { - if x != nil && x.Signal != nil { - return *x.Signal - } - return "" -} - -var File_pluggableharness_tool_v1_tool_proto protoreflect.FileDescriptor - -const file_pluggableharness_tool_v1_tool_proto_rawDesc = "" + - "\n" + - "#pluggableharness/tool/v1/tool.proto\x12\x18pluggableharness.tool.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a'pluggableharness/common/v1/common.proto\x1a'pluggableharness/config/v1/config.proto\x1a'pluggableharness/render/v1/render.proto\x1a'pluggableharness/schema/v1/schema.proto\x1a3pluggableharness/slashcommand/v1/slashcommand.proto\"\x12\n" + - "\x10GetSchemaRequest\"\xd4\x02\n" + - "\x11GetSchemaResponse\x12:\n" + - "\x05tools\x18\x01 \x03(\v2$.pluggableharness.tool.v1.ToolSchemaR\x05tools\x12Y\n" + - "\x0eslash_commands\x18\x02 \x03(\v22.pluggableharness.slashcommand.v1.SlashCommandSpecR\rslashCommands\x12M\n" + - "\rconfig_schema\x18\x03 \x01(\v2(.pluggableharness.config.v1.ConfigSchemaR\fconfigSchema\x12Y\n" + - "\x15supported_hook_points\x18\x04 \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPoints\"C\n" + - "\x10ConfigureRequest\x12/\n" + - "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06config\"\x13\n" + - "\x11ConfigureResponse\"D\n" + - "\x0fConcurrencySpec\x12\x12\n" + - "\x04safe\x18\x01 \x01(\bR\x04safe\x12\x1d\n" + - "\n" + - "key_fields\x18\x02 \x03(\tR\tkeyFields\"\xab\x04\n" + - "\n" + - "ToolSchema\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x126\n" + - "\x04kind\x18\x02 \x01(\x0e2\".pluggableharness.tool.v1.ToolKindR\x04kind\x127\n" + - "\x04risk\x18\x03 \x01(\x0e2#.pluggableharness.tool.v1.RiskClassR\x04risk\x12 \n" + - "\vdescription\x18\x04 \x01(\tR\vdescription\x12E\n" + - "\finput_schema\x18\x05 \x01(\v2\".pluggableharness.schema.v1.SchemaR\vinputSchema\x12G\n" + - "\routput_schema\x18\x06 \x01(\v2\".pluggableharness.schema.v1.SchemaR\foutputSchema\x12\x1c\n" + - "\tstreaming\x18\a \x01(\bR\tstreaming\x12K\n" + - "\vconcurrency\x18\b \x01(\v2).pluggableharness.tool.v1.ConcurrencySpecR\vconcurrency\x12G\n" + - "\x0fdefault_timeout\x18\t \x01(\v2\x19.google.protobuf.DurationH\x00R\x0edefaultTimeout\x88\x01\x01\x12\x1e\n" + - "\n" + - "idempotent\x18\n" + - " \x01(\bR\n" + - "idempotentB\x12\n" + - "\x10_default_timeout\"G\n" + - "\rInvokeRequest\x126\n" + - "\x04call\x18\x01 \x01(\v2\".pluggableharness.tool.v1.ToolCallR\x04call\"K\n" + - "\x0eInvokeResponse\x129\n" + - "\x05event\x18\x01 \x01(\v2#.pluggableharness.tool.v1.ToolEventR\x05event\"\xba\x01\n" + - "\bToolCall\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12\x1b\n" + - "\ttool_name\x18\x02 \x01(\tR\btoolName\x125\n" + - "\targuments\x18\x03 \x01(\v2\x17.google.protobuf.StructR\targuments\x12J\n" + - "\fcall_context\x18\x04 \x01(\v2'.pluggableharness.common.v1.CallContextR\vcallContext\"\xca\x06\n" + - "\tToolEvent\x12T\n" + - "\foutput_chunk\x18\x01 \x01(\v2/.pluggableharness.tool.v1.ToolEvent.OutputChunkH\x00R\voutputChunk\x12J\n" + - "\bprogress\x18\x02 \x01(\v2,.pluggableharness.tool.v1.ToolEvent.ProgressH\x00R\bprogress\x12Z\n" + - "\x0epartial_result\x18\x03 \x01(\v21.pluggableharness.tool.v1.ToolEvent.PartialResultH\x00R\rpartialResult\x12Q\n" + - "\vexit_status\x18\x04 \x01(\v2..pluggableharness.tool.v1.ToolEvent.ExitStatusH\x00R\n" + - "exitStatus\x12>\n" + - "\x06result\x18\x05 \x01(\v2$.pluggableharness.tool.v1.ToolResultH\x00R\x06result\x12;\n" + - "\x05error\x18\x06 \x01(\v2#.pluggableharness.tool.v1.ToolErrorH\x00R\x05error\x1aa\n" + - "\vOutputChunk\x12>\n" + - "\x06stream\x18\x01 \x01(\x0e2&.pluggableharness.tool.v1.OutputStreamR\x06stream\x12\x12\n" + - "\x04data\x18\x02 \x01(\fR\x04data\x1al\n" + - "\bProgress\x12\x18\n" + - "\amessage\x18\x01 \x01(\tR\amessage\x120\n" + - "\x11fraction_complete\x18\x02 \x01(\x01H\x00R\x10fractionComplete\x88\x01\x01B\x14\n" + - "\x12_fraction_complete\x1aB\n" + - "\rPartialResult\x121\n" + - "\apayload\x18\x01 \x01(\v2\x17.google.protobuf.StructR\apayload\x1aQ\n" + - "\n" + - "ExitStatus\x12\x1b\n" + - "\texit_code\x18\x01 \x01(\x05R\bexitCode\x12\x1b\n" + - "\x06signal\x18\x02 \x01(\tH\x00R\x06signal\x88\x01\x01B\t\n" + - "\a_signalB\a\n" + - "\x05event\"?\n" + - "\n" + - "ToolResult\x121\n" + - "\apayload\x18\x01 \x01(\v2\x17.google.protobuf.StructR\apayload\"\xd0\x01\n" + - "\tToolError\x12G\n" + - "\bcategory\x18\x01 \x01(\x0e2+.pluggableharness.tool.v1.ToolErrorCategoryR\bcategory\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\x12\x1c\n" + - "\tretryable\x18\x03 \x01(\bR\tretryable\x126\n" + - "\adetails\x18\x04 \x01(\v2\x17.google.protobuf.StructH\x00R\adetails\x88\x01\x01B\n" + - "\n" + - "\b_details\"P\n" + - "\rRenderRequest\x12\x18\n" + - "\apayload\x18\x01 \x01(\fR\apayload\x12%\n" + - "\x0eschema_version\x18\x02 \x01(\tR\rschemaVersion\"L\n" + - "\x0eRenderResponse\x12:\n" + - "\x04tree\x18\x01 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\x04tree\"H\n" + - "\x0ePreviewRequest\x126\n" + - "\x04call\x18\x01 \x01(\v2\".pluggableharness.tool.v1.ToolCallR\x04call\"S\n" + - "\x0fPreviewResponse\x12@\n" + - "\apreview\x18\x01 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\apreview\"\x11\n" + - "\x0fDescribeRequest\"W\n" + - "\x10DescribeResponse\x12C\n" + - "\bproducer\x18\x01 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\bproducer*s\n" + - "\bToolKind\x12\x19\n" + - "\x15TOOL_KIND_UNSPECIFIED\x10\x00\x12\x16\n" + - "\x12TOOL_KIND_RESOURCE\x10\x01\x12\x19\n" + - "\x15TOOL_KIND_DATA_SOURCE\x10\x02\x12\x19\n" + - "\x15TOOL_KIND_INTERACTIVE\x10\x03*\x9c\x01\n" + - "\tRiskClass\x12\x1a\n" + - "\x16RISK_CLASS_UNSPECIFIED\x10\x00\x12\x18\n" + - "\x14RISK_CLASS_READ_ONLY\x10\x01\x12\x12\n" + - "\x0eRISK_CLASS_LOW\x10\x02\x12\x17\n" + - "\x13RISK_CLASS_MODERATE\x10\x03\x12\x13\n" + - "\x0fRISK_CLASS_HIGH\x10\x04\x12\x17\n" + - "\x13RISK_CLASS_CRITICAL\x10\x05*a\n" + - "\fOutputStream\x12\x1d\n" + - "\x19OUTPUT_STREAM_UNSPECIFIED\x10\x00\x12\x18\n" + - "\x14OUTPUT_STREAM_STDOUT\x10\x01\x12\x18\n" + - "\x14OUTPUT_STREAM_STDERR\x10\x02*\x97\x03\n" + - "\x11ToolErrorCategory\x12#\n" + - "\x1fTOOL_ERROR_CATEGORY_UNSPECIFIED\x10\x00\x12)\n" + - "%TOOL_ERROR_CATEGORY_INVALID_ARGUMENTS\x10\x01\x12!\n" + - "\x1dTOOL_ERROR_CATEGORY_NOT_FOUND\x10\x02\x12)\n" + - "%TOOL_ERROR_CATEGORY_PERMISSION_DENIED\x10\x03\x12(\n" + - "$TOOL_ERROR_CATEGORY_EXECUTION_FAILED\x10\x04\x12\x1f\n" + - "\x1bTOOL_ERROR_CATEGORY_TIMEOUT\x10\x05\x12,\n" + - "(TOOL_ERROR_CATEGORY_CONCURRENCY_CONFLICT\x10\x06\x12!\n" + - "\x1dTOOL_ERROR_CATEGORY_CANCELLED\x10\a\x12'\n" + - "#TOOL_ERROR_CATEGORY_PROCESS_CRASHED\x10\b\x12\x1f\n" + - "\x1bTOOL_ERROR_CATEGORY_UNKNOWN\x10\t2\xd8\x04\n" + - "\vToolService\x12d\n" + - "\tGetSchema\x12*.pluggableharness.tool.v1.GetSchemaRequest\x1a+.pluggableharness.tool.v1.GetSchemaResponse\x12d\n" + - "\tConfigure\x12*.pluggableharness.tool.v1.ConfigureRequest\x1a+.pluggableharness.tool.v1.ConfigureResponse\x12]\n" + - "\x06Invoke\x12'.pluggableharness.tool.v1.InvokeRequest\x1a(.pluggableharness.tool.v1.InvokeResponse0\x01\x12[\n" + - "\x06Render\x12'.pluggableharness.tool.v1.RenderRequest\x1a(.pluggableharness.tool.v1.RenderResponse\x12^\n" + - "\aPreview\x12(.pluggableharness.tool.v1.PreviewRequest\x1a).pluggableharness.tool.v1.PreviewResponse\x12a\n" + - "\bDescribe\x12).pluggableharness.tool.v1.DescribeRequest\x1a*.pluggableharness.tool.v1.DescribeResponseB pluggableharness.tool.v1.ToolSchema - 26, // 1: pluggableharness.tool.v1.GetSchemaResponse.slash_commands:type_name -> pluggableharness.slashcommand.v1.SlashCommandSpec - 27, // 2: pluggableharness.tool.v1.GetSchemaResponse.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema - 28, // 3: pluggableharness.tool.v1.GetSchemaResponse.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint - 29, // 4: pluggableharness.tool.v1.ConfigureRequest.config:type_name -> google.protobuf.Struct - 0, // 5: pluggableharness.tool.v1.ToolSchema.kind:type_name -> pluggableharness.tool.v1.ToolKind - 1, // 6: pluggableharness.tool.v1.ToolSchema.risk:type_name -> pluggableharness.tool.v1.RiskClass - 30, // 7: pluggableharness.tool.v1.ToolSchema.input_schema:type_name -> pluggableharness.schema.v1.Schema - 30, // 8: pluggableharness.tool.v1.ToolSchema.output_schema:type_name -> pluggableharness.schema.v1.Schema - 8, // 9: pluggableharness.tool.v1.ToolSchema.concurrency:type_name -> pluggableharness.tool.v1.ConcurrencySpec - 31, // 10: pluggableharness.tool.v1.ToolSchema.default_timeout:type_name -> google.protobuf.Duration - 12, // 11: pluggableharness.tool.v1.InvokeRequest.call:type_name -> pluggableharness.tool.v1.ToolCall - 13, // 12: pluggableharness.tool.v1.InvokeResponse.event:type_name -> pluggableharness.tool.v1.ToolEvent - 29, // 13: pluggableharness.tool.v1.ToolCall.arguments:type_name -> google.protobuf.Struct - 32, // 14: pluggableharness.tool.v1.ToolCall.call_context:type_name -> pluggableharness.common.v1.CallContext - 22, // 15: pluggableharness.tool.v1.ToolEvent.output_chunk:type_name -> pluggableharness.tool.v1.ToolEvent.OutputChunk - 23, // 16: pluggableharness.tool.v1.ToolEvent.progress:type_name -> pluggableharness.tool.v1.ToolEvent.Progress - 24, // 17: pluggableharness.tool.v1.ToolEvent.partial_result:type_name -> pluggableharness.tool.v1.ToolEvent.PartialResult - 25, // 18: pluggableharness.tool.v1.ToolEvent.exit_status:type_name -> pluggableharness.tool.v1.ToolEvent.ExitStatus - 14, // 19: pluggableharness.tool.v1.ToolEvent.result:type_name -> pluggableharness.tool.v1.ToolResult - 15, // 20: pluggableharness.tool.v1.ToolEvent.error:type_name -> pluggableharness.tool.v1.ToolError - 29, // 21: pluggableharness.tool.v1.ToolResult.payload:type_name -> google.protobuf.Struct - 3, // 22: pluggableharness.tool.v1.ToolError.category:type_name -> pluggableharness.tool.v1.ToolErrorCategory - 29, // 23: pluggableharness.tool.v1.ToolError.details:type_name -> google.protobuf.Struct - 33, // 24: pluggableharness.tool.v1.RenderResponse.tree:type_name -> pluggableharness.render.v1.RenderTree - 12, // 25: pluggableharness.tool.v1.PreviewRequest.call:type_name -> pluggableharness.tool.v1.ToolCall - 33, // 26: pluggableharness.tool.v1.PreviewResponse.preview:type_name -> pluggableharness.render.v1.RenderTree - 34, // 27: pluggableharness.tool.v1.DescribeResponse.producer:type_name -> pluggableharness.common.v1.ProducerRef - 2, // 28: pluggableharness.tool.v1.ToolEvent.OutputChunk.stream:type_name -> pluggableharness.tool.v1.OutputStream - 29, // 29: pluggableharness.tool.v1.ToolEvent.PartialResult.payload:type_name -> google.protobuf.Struct - 4, // 30: pluggableharness.tool.v1.ToolService.GetSchema:input_type -> pluggableharness.tool.v1.GetSchemaRequest - 6, // 31: pluggableharness.tool.v1.ToolService.Configure:input_type -> pluggableharness.tool.v1.ConfigureRequest - 10, // 32: pluggableharness.tool.v1.ToolService.Invoke:input_type -> pluggableharness.tool.v1.InvokeRequest - 16, // 33: pluggableharness.tool.v1.ToolService.Render:input_type -> pluggableharness.tool.v1.RenderRequest - 18, // 34: pluggableharness.tool.v1.ToolService.Preview:input_type -> pluggableharness.tool.v1.PreviewRequest - 20, // 35: pluggableharness.tool.v1.ToolService.Describe:input_type -> pluggableharness.tool.v1.DescribeRequest - 5, // 36: pluggableharness.tool.v1.ToolService.GetSchema:output_type -> pluggableharness.tool.v1.GetSchemaResponse - 7, // 37: pluggableharness.tool.v1.ToolService.Configure:output_type -> pluggableharness.tool.v1.ConfigureResponse - 11, // 38: pluggableharness.tool.v1.ToolService.Invoke:output_type -> pluggableharness.tool.v1.InvokeResponse - 17, // 39: pluggableharness.tool.v1.ToolService.Render:output_type -> pluggableharness.tool.v1.RenderResponse - 19, // 40: pluggableharness.tool.v1.ToolService.Preview:output_type -> pluggableharness.tool.v1.PreviewResponse - 21, // 41: pluggableharness.tool.v1.ToolService.Describe:output_type -> pluggableharness.tool.v1.DescribeResponse - 36, // [36:42] is the sub-list for method output_type - 30, // [30:36] is the sub-list for method input_type - 30, // [30:30] is the sub-list for extension type_name - 30, // [30:30] is the sub-list for extension extendee - 0, // [0:30] is the sub-list for field type_name -} - -func init() { file_pluggableharness_tool_v1_tool_proto_init() } -func file_pluggableharness_tool_v1_tool_proto_init() { - if File_pluggableharness_tool_v1_tool_proto != nil { - return - } - file_pluggableharness_tool_v1_tool_proto_msgTypes[5].OneofWrappers = []any{} - file_pluggableharness_tool_v1_tool_proto_msgTypes[9].OneofWrappers = []any{ - (*ToolEvent_OutputChunk_)(nil), - (*ToolEvent_Progress_)(nil), - (*ToolEvent_PartialResult_)(nil), - (*ToolEvent_ExitStatus_)(nil), - (*ToolEvent_Result)(nil), - (*ToolEvent_Error)(nil), - } - file_pluggableharness_tool_v1_tool_proto_msgTypes[11].OneofWrappers = []any{} - file_pluggableharness_tool_v1_tool_proto_msgTypes[19].OneofWrappers = []any{} - file_pluggableharness_tool_v1_tool_proto_msgTypes[21].OneofWrappers = []any{} - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_tool_v1_tool_proto_rawDesc), len(file_pluggableharness_tool_v1_tool_proto_rawDesc)), - NumEnums: 4, - NumMessages: 22, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_pluggableharness_tool_v1_tool_proto_goTypes, - DependencyIndexes: file_pluggableharness_tool_v1_tool_proto_depIdxs, - EnumInfos: file_pluggableharness_tool_v1_tool_proto_enumTypes, - MessageInfos: file_pluggableharness_tool_v1_tool_proto_msgTypes, - }.Build() - File_pluggableharness_tool_v1_tool_proto = out.File - file_pluggableharness_tool_v1_tool_proto_goTypes = nil - file_pluggableharness_tool_v1_tool_proto_depIdxs = nil -} diff --git a/pkg/tool/proto/v1/types.pb.go b/pkg/tool/proto/v1/types.pb.go new file mode 100644 index 0000000..38e7139 --- /dev/null +++ b/pkg/tool/proto/v1/types.pb.go @@ -0,0 +1,620 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/tool/v1/types.proto + +package toolv1 + +import ( + v11 "github.com/pluggableharness/agent/pkg/common/proto/v1" + v1 "github.com/pluggableharness/agent/pkg/schema/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// ToolKind is the axis that drives the plan/apply gate, per tool.md §2. +// Deliberately separate from RiskClass: kind is the binary the gate +// mechanically needs, risk is a finer-grained classification within it. +type ToolKind int32 + +const ( + // Zero value. Never valid for a real operation; its presence on the wire + // means a caller forgot to set the field. + ToolKind_TOOL_KIND_UNSPECIFIED ToolKind = 0 + // Mutating. Gated behind the plan/apply approval gate. + ToolKind_TOOL_KIND_RESOURCE ToolKind = 1 + // Read-only. Executes freely, subject only to the policy precheck + // (agent-loop.md §5.4). + ToolKind_TOOL_KIND_DATA_SOURCE ToolKind = 2 + // Blocks the current turn for human input, per tool.md §2.1. Produces no + // state mutation of its own — the human's answer becomes the result. + ToolKind_TOOL_KIND_INTERACTIVE ToolKind = 3 +) + +// Enum value maps for ToolKind. +var ( + ToolKind_name = map[int32]string{ + 0: "TOOL_KIND_UNSPECIFIED", + 1: "TOOL_KIND_RESOURCE", + 2: "TOOL_KIND_DATA_SOURCE", + 3: "TOOL_KIND_INTERACTIVE", + } + ToolKind_value = map[string]int32{ + "TOOL_KIND_UNSPECIFIED": 0, + "TOOL_KIND_RESOURCE": 1, + "TOOL_KIND_DATA_SOURCE": 2, + "TOOL_KIND_INTERACTIVE": 3, + } +) + +func (x ToolKind) Enum() *ToolKind { + p := new(ToolKind) + *p = x + return p +} + +func (x ToolKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ToolKind) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_tool_v1_types_proto_enumTypes[0].Descriptor() +} + +func (ToolKind) Type() protoreflect.EnumType { + return &file_pluggableharness_tool_v1_types_proto_enumTypes[0] +} + +func (x ToolKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ToolKind.Descriptor instead. +func (ToolKind) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_types_proto_rawDescGZIP(), []int{0} +} + +// RiskClass classifies an operation's blast radius, per tool.md §2. Orthogonal +// to ToolKind: kind determines whether the plan/apply gate applies at all, +// risk determines how significant the gated (or inherently ungated) action is. +type RiskClass int32 + +const ( + // Zero value. Never valid for a real operation; its presence on the wire + // means a caller forgot to set the field. + RiskClass_RISK_CLASS_UNSPECIFIED RiskClass = 0 + // Inherently unable to mutate anything the plugin controls. MUST be used + // for TOOL_KIND_DATA_SOURCE and TOOL_KIND_INTERACTIVE alike — neither + // mutates nor reads anything external, so neither has a blast radius to + // classify. + RiskClass_RISK_CLASS_READ_ONLY RiskClass = 1 + // A resource operation with narrow, easily-reversible blast radius, e.g. + // a write to a scratch path. + RiskClass_RISK_CLASS_LOW RiskClass = 2 + // A resource operation with real but bounded blast radius, e.g. editing + // a tracked source file. + RiskClass_RISK_CLASS_MODERATE RiskClass = 3 + // A resource operation with broad or hard-to-predict blast radius, e.g. + // arbitrary shell execution. + RiskClass_RISK_CLASS_HIGH RiskClass = 4 + // A resource operation capable of irreversible or wide-blast-radius + // action, e.g. `rm -rf`, a force-push, or spawning a sub-agent with + // further unattended write access. + // + // A TOOL_KIND_RESOURCE operation MUST declare one of LOW/MODERATE/HIGH/ + // CRITICAL — never READ_ONLY. There is no resource with read_only risk. + RiskClass_RISK_CLASS_CRITICAL RiskClass = 5 +) + +// Enum value maps for RiskClass. +var ( + RiskClass_name = map[int32]string{ + 0: "RISK_CLASS_UNSPECIFIED", + 1: "RISK_CLASS_READ_ONLY", + 2: "RISK_CLASS_LOW", + 3: "RISK_CLASS_MODERATE", + 4: "RISK_CLASS_HIGH", + 5: "RISK_CLASS_CRITICAL", + } + RiskClass_value = map[string]int32{ + "RISK_CLASS_UNSPECIFIED": 0, + "RISK_CLASS_READ_ONLY": 1, + "RISK_CLASS_LOW": 2, + "RISK_CLASS_MODERATE": 3, + "RISK_CLASS_HIGH": 4, + "RISK_CLASS_CRITICAL": 5, + } +) + +func (x RiskClass) Enum() *RiskClass { + p := new(RiskClass) + *p = x + return p +} + +func (x RiskClass) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (RiskClass) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_tool_v1_types_proto_enumTypes[1].Descriptor() +} + +func (RiskClass) Type() protoreflect.EnumType { + return &file_pluggableharness_tool_v1_types_proto_enumTypes[1] +} + +func (x RiskClass) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use RiskClass.Descriptor instead. +func (RiskClass) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_types_proto_rawDescGZIP(), []int{1} +} + +// ConcurrencySpec declares whether this operation's Invoke calls may run +// concurrently against the same provider process, per tool.md §5. +type ConcurrencySpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + // MUST be set, per operation, except for TOOL_KIND_INTERACTIVE. false (or + // an absent/unset default) means the kernel MUST NOT run any other Invoke + // call against this provider process concurrently with this one — a + // coarse, provider-wide lock. true means concurrent Invoke calls against + // this provider are generally safe. A provider that does not populate + // this field at all MUST be treated by the kernel as false — the + // conservative default. + Safe bool `protobuf:"varint,1,opt,name=safe,proto3" json:"safe,omitempty"` + // Only meaningful when safe == true. Names of this operation's + // input_schema fields whose value(s) form a serialization key. The + // kernel computes key = (provider_name, tool_name, value(key_fields)) and + // MUST serialize calls sharing an identical key, while still freely + // parallelizing calls with distinct keys. Omitting key_fields under + // safe == true asserts that no two calls to this operation can ever + // conflict — a strong claim, true for e.g. web_search, false for e.g. + // write_file. + KeyFields []string `protobuf:"bytes,2,rep,name=key_fields,json=keyFields,proto3" json:"key_fields,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConcurrencySpec) Reset() { + *x = ConcurrencySpec{} + mi := &file_pluggableharness_tool_v1_types_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConcurrencySpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConcurrencySpec) ProtoMessage() {} + +func (x *ConcurrencySpec) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_types_proto_msgTypes[0] + 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 ConcurrencySpec.ProtoReflect.Descriptor instead. +func (*ConcurrencySpec) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_types_proto_rawDescGZIP(), []int{0} +} + +func (x *ConcurrencySpec) GetSafe() bool { + if x != nil { + return x.Safe + } + return false +} + +func (x *ConcurrencySpec) GetKeyFields() []string { + if x != nil { + return x.KeyFields + } + return nil +} + +// ToolSchema declares one operation this provider exposes, per tool.md §2. +type ToolSchema struct { + state protoimpl.MessageState `protogen:"open.v1"` + // MUST — unique within this provider's namespace, e.g. "read_file". + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // MUST — drives the plan/apply gate. + Kind ToolKind `protobuf:"varint,2,opt,name=kind,proto3,enum=pluggableharness.tool.v1.ToolKind" json:"kind,omitempty"` + // MUST — see RiskClass. + Risk RiskClass `protobuf:"varint,3,opt,name=risk,proto3,enum=pluggableharness.tool.v1.RiskClass" json:"risk,omitempty"` + // MUST — shown to the model for tool selection and in plan diffs. + Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` + // MUST — the common JSON-Schema subset per model.md §6, describing the + // shape of ToolCall.arguments for this operation. + InputSchema *v1.Schema `protobuf:"bytes,5,opt,name=input_schema,json=inputSchema,proto3" json:"input_schema,omitempty"` + // MUST — the common JSON-Schema subset per model.md §6, describing the + // shape of ToolResult.payload for this operation. + OutputSchema *v1.Schema `protobuf:"bytes,6,opt,name=output_schema,json=outputSchema,proto3" json:"output_schema,omitempty"` + // MUST — true if Invoke may emit intermediate ToolEvents (output_chunk, + // progress, partial_result) before the terminal event; false if Invoke + // always emits exactly one terminal event with no lead-up. + Streaming bool `protobuf:"varint,7,opt,name=streaming,proto3" json:"streaming,omitempty"` + // MUST, except MUST NOT be meaningfully set for TOOL_KIND_INTERACTIVE. + Concurrency *ConcurrencySpec `protobuf:"bytes,8,opt,name=concurrency,proto3" json:"concurrency,omitempty"` + // SHOULD — the deadline the kernel applies to Invoke for this operation + // absent an agent.hcl override. Absent means the kernel's global default + // applies instead (configuration/settings-and-global.md). + DefaultTimeout *durationpb.Duration `protobuf:"bytes,9,opt,name=default_timeout,json=defaultTimeout,proto3,oneof" json:"default_timeout,omitempty"` + // True iff re-running this operation with identical arguments cannot + // produce a different end state than running it once. Gates whether the + // kernel MAY auto-retry a retryable ToolError for a TOOL_KIND_RESOURCE + // operation — see conformance.md#error-taxonomy's retry interaction. + // TOOL_KIND_DATA_SOURCE operations are implicitly safe to retry + // regardless of this field. + Idempotent bool `protobuf:"varint,10,opt,name=idempotent,proto3" json:"idempotent,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ToolSchema) Reset() { + *x = ToolSchema{} + mi := &file_pluggableharness_tool_v1_types_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ToolSchema) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolSchema) ProtoMessage() {} + +func (x *ToolSchema) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_types_proto_msgTypes[1] + 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 ToolSchema.ProtoReflect.Descriptor instead. +func (*ToolSchema) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_types_proto_rawDescGZIP(), []int{1} +} + +func (x *ToolSchema) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ToolSchema) GetKind() ToolKind { + if x != nil { + return x.Kind + } + return ToolKind_TOOL_KIND_UNSPECIFIED +} + +func (x *ToolSchema) GetRisk() RiskClass { + if x != nil { + return x.Risk + } + return RiskClass_RISK_CLASS_UNSPECIFIED +} + +func (x *ToolSchema) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ToolSchema) GetInputSchema() *v1.Schema { + if x != nil { + return x.InputSchema + } + return nil +} + +func (x *ToolSchema) GetOutputSchema() *v1.Schema { + if x != nil { + return x.OutputSchema + } + return nil +} + +func (x *ToolSchema) GetStreaming() bool { + if x != nil { + return x.Streaming + } + return false +} + +func (x *ToolSchema) GetConcurrency() *ConcurrencySpec { + if x != nil { + return x.Concurrency + } + return nil +} + +func (x *ToolSchema) GetDefaultTimeout() *durationpb.Duration { + if x != nil { + return x.DefaultTimeout + } + return nil +} + +func (x *ToolSchema) GetIdempotent() bool { + if x != nil { + return x.Idempotent + } + return false +} + +// ToolCall is one request to execute an operation, per tool.md §4. +type ToolCall struct { + state protoimpl.MessageState `protogen:"open.v1"` + // MUST — kernel-assigned. Echoed in every ToolEvent for this call, for + // correlation. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // MUST — matches a ToolSchema.name from this provider's GetSchema + // response. + ToolName string `protobuf:"bytes,2,opt,name=tool_name,json=toolName,proto3" json:"tool_name,omitempty"` + // MUST — already-parsed JSON conforming to that ToolSchema's + // input_schema. + Arguments *structpb.Struct `protobuf:"bytes,3,opt,name=arguments,proto3" json:"arguments,omitempty"` + // MUST be set by the kernel. Carries the session_id/turn_id this call + // executes for — what the plugin echoes back on its own + // KernelCallbackService.Emit/Log calls for attribution — and the + // session's working_directory, which any process-backed operation + // (the reference catalog's exec.bash, read_file, and similar) MUST + // resolve relative-path arguments against. See + // pluggableharness.common.v1.CallContext. + CallContext *v11.CallContext `protobuf:"bytes,4,opt,name=call_context,json=callContext,proto3" json:"call_context,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ToolCall) Reset() { + *x = ToolCall{} + mi := &file_pluggableharness_tool_v1_types_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ToolCall) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolCall) ProtoMessage() {} + +func (x *ToolCall) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_types_proto_msgTypes[2] + 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 ToolCall.ProtoReflect.Descriptor instead. +func (*ToolCall) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_types_proto_rawDescGZIP(), []int{2} +} + +func (x *ToolCall) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ToolCall) GetToolName() string { + if x != nil { + return x.ToolName + } + return "" +} + +func (x *ToolCall) GetArguments() *structpb.Struct { + if x != nil { + return x.Arguments + } + return nil +} + +func (x *ToolCall) GetCallContext() *v11.CallContext { + if x != nil { + return x.CallContext + } + return nil +} + +// ToolResult is the terminal, successful outcome of an Invoke call. +type ToolResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // MUST conform to the ToolSchema.output_schema declared for this call's + // tool_name; the kernel strictly validates this — a non-conforming + // payload becomes a ToolError with category TOOL_ERROR_CATEGORY_UNKNOWN + // rather than being passed through to history. + Payload *structpb.Struct `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ToolResult) Reset() { + *x = ToolResult{} + mi := &file_pluggableharness_tool_v1_types_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ToolResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolResult) ProtoMessage() {} + +func (x *ToolResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_tool_v1_types_proto_msgTypes[3] + 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 ToolResult.ProtoReflect.Descriptor instead. +func (*ToolResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_tool_v1_types_proto_rawDescGZIP(), []int{3} +} + +func (x *ToolResult) GetPayload() *structpb.Struct { + if x != nil { + return x.Payload + } + return nil +} + +var File_pluggableharness_tool_v1_types_proto protoreflect.FileDescriptor + +const file_pluggableharness_tool_v1_types_proto_rawDesc = "" + + "\n" + + "$pluggableharness/tool/v1/types.proto\x12\x18pluggableharness.tool.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/schema/v1/types.proto\"D\n" + + "\x0fConcurrencySpec\x12\x12\n" + + "\x04safe\x18\x01 \x01(\bR\x04safe\x12\x1d\n" + + "\n" + + "key_fields\x18\x02 \x03(\tR\tkeyFields\"\xab\x04\n" + + "\n" + + "ToolSchema\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x126\n" + + "\x04kind\x18\x02 \x01(\x0e2\".pluggableharness.tool.v1.ToolKindR\x04kind\x127\n" + + "\x04risk\x18\x03 \x01(\x0e2#.pluggableharness.tool.v1.RiskClassR\x04risk\x12 \n" + + "\vdescription\x18\x04 \x01(\tR\vdescription\x12E\n" + + "\finput_schema\x18\x05 \x01(\v2\".pluggableharness.schema.v1.SchemaR\vinputSchema\x12G\n" + + "\routput_schema\x18\x06 \x01(\v2\".pluggableharness.schema.v1.SchemaR\foutputSchema\x12\x1c\n" + + "\tstreaming\x18\a \x01(\bR\tstreaming\x12K\n" + + "\vconcurrency\x18\b \x01(\v2).pluggableharness.tool.v1.ConcurrencySpecR\vconcurrency\x12G\n" + + "\x0fdefault_timeout\x18\t \x01(\v2\x19.google.protobuf.DurationH\x00R\x0edefaultTimeout\x88\x01\x01\x12\x1e\n" + + "\n" + + "idempotent\x18\n" + + " \x01(\bR\n" + + "idempotentB\x12\n" + + "\x10_default_timeout\"\xba\x01\n" + + "\bToolCall\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1b\n" + + "\ttool_name\x18\x02 \x01(\tR\btoolName\x125\n" + + "\targuments\x18\x03 \x01(\v2\x17.google.protobuf.StructR\targuments\x12J\n" + + "\fcall_context\x18\x04 \x01(\v2'.pluggableharness.common.v1.CallContextR\vcallContext\"?\n" + + "\n" + + "ToolResult\x121\n" + + "\apayload\x18\x01 \x01(\v2\x17.google.protobuf.StructR\apayload*s\n" + + "\bToolKind\x12\x19\n" + + "\x15TOOL_KIND_UNSPECIFIED\x10\x00\x12\x16\n" + + "\x12TOOL_KIND_RESOURCE\x10\x01\x12\x19\n" + + "\x15TOOL_KIND_DATA_SOURCE\x10\x02\x12\x19\n" + + "\x15TOOL_KIND_INTERACTIVE\x10\x03*\x9c\x01\n" + + "\tRiskClass\x12\x1a\n" + + "\x16RISK_CLASS_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14RISK_CLASS_READ_ONLY\x10\x01\x12\x12\n" + + "\x0eRISK_CLASS_LOW\x10\x02\x12\x17\n" + + "\x13RISK_CLASS_MODERATE\x10\x03\x12\x13\n" + + "\x0fRISK_CLASS_HIGH\x10\x04\x12\x17\n" + + "\x13RISK_CLASS_CRITICAL\x10\x05B pluggableharness.tool.v1.ToolKind + 1, // 1: pluggableharness.tool.v1.ToolSchema.risk:type_name -> pluggableharness.tool.v1.RiskClass + 6, // 2: pluggableharness.tool.v1.ToolSchema.input_schema:type_name -> pluggableharness.schema.v1.Schema + 6, // 3: pluggableharness.tool.v1.ToolSchema.output_schema:type_name -> pluggableharness.schema.v1.Schema + 2, // 4: pluggableharness.tool.v1.ToolSchema.concurrency:type_name -> pluggableharness.tool.v1.ConcurrencySpec + 7, // 5: pluggableharness.tool.v1.ToolSchema.default_timeout:type_name -> google.protobuf.Duration + 8, // 6: pluggableharness.tool.v1.ToolCall.arguments:type_name -> google.protobuf.Struct + 9, // 7: pluggableharness.tool.v1.ToolCall.call_context:type_name -> pluggableharness.common.v1.CallContext + 8, // 8: pluggableharness.tool.v1.ToolResult.payload:type_name -> google.protobuf.Struct + 9, // [9:9] is the sub-list for method output_type + 9, // [9:9] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name +} + +func init() { file_pluggableharness_tool_v1_types_proto_init() } +func file_pluggableharness_tool_v1_types_proto_init() { + if File_pluggableharness_tool_v1_types_proto != nil { + return + } + file_pluggableharness_tool_v1_types_proto_msgTypes[1].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_tool_v1_types_proto_rawDesc), len(file_pluggableharness_tool_v1_types_proto_rawDesc)), + NumEnums: 2, + NumMessages: 4, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_tool_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_tool_v1_types_proto_depIdxs, + EnumInfos: file_pluggableharness_tool_v1_types_proto_enumTypes, + MessageInfos: file_pluggableharness_tool_v1_types_proto_msgTypes, + }.Build() + File_pluggableharness_tool_v1_types_proto = out.File + file_pluggableharness_tool_v1_types_proto_goTypes = nil + file_pluggableharness_tool_v1_types_proto_depIdxs = nil +} diff --git a/pkg/tool/server.go b/pkg/tool/server.go new file mode 100644 index 0000000..ba63f97 --- /dev/null +++ b/pkg/tool/server.go @@ -0,0 +1,160 @@ +package tool + +import ( + "context" + "errors" + "fmt" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" + toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" +) + +// callbackCtxKey is the unexported context key ContextWithCallback and +// CallbackFromContext use, per .claude/rules/go-architecture.md's "Context +// keys are an unexported type" rule. +type callbackCtxKey struct{} + +// ContextWithCallback returns a copy of ctx carrying cb, retrievable with +// CallbackFromContext. Service attaches its own *plugin.Callback to the +// context it passes into every Provider method, so an implementation that +// needs to call back into the kernel (Emit a progress update outside the +// Invoke stream, RunSession for a spawn_subagent-shaped tool, ...) can +// reach it without every Provider method signature threading a +// *plugin.Callback through by hand. +func ContextWithCallback(ctx context.Context, cb *plugin.Callback) context.Context { + return context.WithValue(ctx, callbackCtxKey{}, cb) +} + +// CallbackFromContext retrieves the *plugin.Callback ContextWithCallback +// attached to ctx, if any. +func CallbackFromContext(ctx context.Context) (*plugin.Callback, bool) { + cb, ok := ctx.Value(callbackCtxKey{}).(*plugin.Callback) + return cb, ok +} + +// Service adapts a Provider onto the generated toolv1.ToolServiceServer, +// implementing plugin.Service so it can be passed to plugin.Config.Services. +type Service struct { + toolv1.UnimplementedToolServiceServer + + identity plugin.Identity + callback *plugin.Callback + impl Provider +} + +var _ plugin.Service = (*Service)(nil) +var _ toolv1.ToolServiceServer = (*Service)(nil) + +// NewService builds a *Service adapting p onto ToolServiceServer. identity +// is this plugin build's own self-reported identity, returned verbatim by +// Describe; callback is the lazily-dialed kernel-callback handle attached +// to every context Service passes into p (see ContextWithCallback). +func NewService(p Provider, identity plugin.Identity, callback *plugin.Callback) *Service { + return &Service{identity: identity, callback: callback, impl: p} +} + +// Register registers ToolService on s, satisfying plugin.Service. +func (s *Service) Register(g *grpc.Server) { + toolv1.RegisterToolServiceServer(g, s) +} + +// ctx returns base with this Service's callback attached, for handing to +// the wrapped Provider. +func (s *Service) ctx(base context.Context) context.Context { + return ContextWithCallback(base, s.callback) +} + +// GetSchema implements toolv1.ToolServiceServer. +func (s *Service) GetSchema(ctx context.Context, _ *toolv1.GetSchemaRequest) (*toolv1.GetSchemaResponse, error) { + resp, err := BuildGetSchemaResponse(s.ctx(ctx), s.impl) + if err != nil { + return nil, ToStatusError(&Error{Category: ErrorCategoryUnknown, Message: err.Error(), Retryable: false}) + } + return resp, nil +} + +// Configure implements toolv1.ToolServiceServer. +func (s *Service) Configure(ctx context.Context, req *toolv1.ConfigureRequest) (*toolv1.ConfigureResponse, error) { + cfg := structToMap(req.GetConfig()) + if err := s.impl.Configure(s.ctx(ctx), cfg); err != nil { + var te *Error + if errors.As(err, &te) { + return nil, ToStatusError(te) + } + return nil, ToStatusError(&Error{Category: ErrorCategoryInvalidArguments, Message: err.Error(), Retryable: false}) + } + return &toolv1.ConfigureResponse{}, nil +} + +// Invoke implements toolv1.ToolServiceServer. Server-streaming: it decodes +// the request's Call, hands it and a *Stream to the wrapped Provider, +// and treats a cancelled context as normal control flow rather than a +// failed RPC, per docs/specifications/tool/README.md#transport--lifecycle. +func (s *Service) Invoke(req *toolv1.InvokeRequest, grpcStream toolv1.ToolService_InvokeServer) error { + call, err := fromProtoCall(req.GetCall()) + if err != nil { + return ToStatusError(&Error{Category: ErrorCategoryInvalidArguments, Message: fmt.Sprintf("tool: invoke: %v", err), Retryable: false}) + } + + st := newStream(grpcStream) + invokeErr := s.impl.Invoke(s.ctx(grpcStream.Context()), call, st) + + switch { + case invokeErr == nil: + if !st.closedTerminal() { + return fmt.Errorf("tool: invoke: %s: provider returned without sending a terminal result or error event", call.ToolName) + } + return nil + case errors.Is(invokeErr, context.Canceled), status.Code(invokeErr) == codes.Canceled: + // Cancellation is normal control flow (README.md#transport--lifecycle), + // never surfaced as an application error. + return nil + default: + return ToStatusError(&Error{Category: ErrorCategoryUnknown, Message: invokeErr.Error(), Retryable: false}) + } +} + +// Render implements toolv1.ToolServiceServer. Returns codes.Unimplemented +// if the wrapped Provider does not additionally implement Renderer, per +// docs/specifications/tool/protocol.md#render's "MAY be implemented". +func (s *Service) Render(ctx context.Context, req *toolv1.RenderRequest) (*toolv1.RenderResponse, error) { + r, ok := s.impl.(Renderer) + if !ok { + return nil, status.Error(codes.Unimplemented, "tool: render not implemented by this provider") + } + tree, err := r.Render(s.ctx(ctx), req.GetPayload(), req.GetSchemaVersion()) + if err != nil { + return nil, ToStatusError(&Error{Category: ErrorCategoryUnknown, Message: err.Error(), Retryable: false}) + } + return &toolv1.RenderResponse{Tree: tree}, nil +} + +// Preview implements toolv1.ToolServiceServer. Returns codes.Unimplemented +// if the wrapped Provider does not additionally implement Previewer, per +// docs/specifications/tool/protocol.md#preview's "MAY be implemented". +func (s *Service) Preview(ctx context.Context, req *toolv1.PreviewRequest) (*toolv1.PreviewResponse, error) { + p, ok := s.impl.(Previewer) + if !ok { + return nil, status.Error(codes.Unimplemented, "tool: preview not implemented by this provider") + } + call, err := fromProtoCall(req.GetCall()) + if err != nil { + return nil, ToStatusError(&Error{Category: ErrorCategoryInvalidArguments, Message: fmt.Sprintf("tool: preview: %v", err), Retryable: false}) + } + tree, err := p.Preview(s.ctx(ctx), call) + if err != nil { + return nil, ToStatusError(&Error{Category: ErrorCategoryUnknown, Message: err.Error(), Retryable: false}) + } + return &toolv1.PreviewResponse{Preview: tree}, nil +} + +// Describe implements toolv1.ToolServiceServer directly from s.identity, +// per docs/specifications/tool/protocol.md#describe. +func (s *Service) Describe(context.Context, *toolv1.DescribeRequest) (*toolv1.DescribeResponse, error) { + return &toolv1.DescribeResponse{Producer: s.identity.ProducerRef(commonv1.Category_CATEGORY_TOOL)}, nil +} diff --git a/pkg/tool/server_test.go b/pkg/tool/server_test.go new file mode 100644 index 0000000..0637fef --- /dev/null +++ b/pkg/tool/server_test.go @@ -0,0 +1,398 @@ +package tool_test + +import ( + "context" + "errors" + "io" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" + "github.com/pluggableharness/agent/pkg/tool" + toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" +) + +func TestServiceGetSchema(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + schemaFunc: func(context.Context) ([]*tool.Schema, error) { + return []*tool.Schema{validSchema("read_file")}, nil + }, + } + client := newTestClient(t, p) + + resp, err := client.GetSchema(t.Context(), &toolv1.GetSchemaRequest{}) + if err != nil { + t.Fatalf("GetSchema: %v", err) + } + if len(resp.GetTools()) != 1 || resp.GetTools()[0].GetName() != "read_file" { + t.Errorf("Tools = %v", resp.GetTools()) + } +} + +func TestServiceGetSchemaError(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + schemaFunc: func(context.Context) ([]*tool.Schema, error) { return nil, errors.New("boom") }, + } + client := newTestClient(t, p) + + _, err := client.GetSchema(t.Context(), &toolv1.GetSchemaRequest{}) + st, ok := status.FromError(err) + if !ok { + t.Fatalf("GetSchema error is not a *status.Status: %v", err) + } + if st.Code() != codes.Internal { + t.Errorf("code = %v, want %v", st.Code(), codes.Internal) + } +} + +func TestServiceConfigure(t *testing.T) { + t.Parallel() + + var gotConfig map[string]any + p := &fakeProvider{ + configureFunc: func(_ context.Context, config map[string]any) error { + gotConfig = config + return nil + }, + } + client := newTestClient(t, p) + + cfg, err := structpb.NewStruct(map[string]any{"root": "/tmp"}) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + if _, err := client.Configure(t.Context(), &toolv1.ConfigureRequest{Config: cfg}); err != nil { + t.Fatalf("Configure: %v", err) + } + if gotConfig["root"] != "/tmp" { + t.Errorf("Provider.Configure received config = %v", gotConfig) + } +} + +func TestServiceConfigureRejectsWithError(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + configureFunc: func(context.Context, map[string]any) error { + return &tool.Error{Category: tool.ErrorCategoryInvalidArguments, Message: "missing root", Retryable: false} + }, + } + client := newTestClient(t, p) + + _, err := client.Configure(t.Context(), &toolv1.ConfigureRequest{}) + st, ok := status.FromError(err) + if !ok { + t.Fatalf("Configure error is not a *status.Status: %v", err) + } + if st.Code() != codes.InvalidArgument { + t.Errorf("code = %v, want %v", st.Code(), codes.InvalidArgument) + } + if st.Message() != "missing root" { + t.Errorf("message = %q, want %q", st.Message(), "missing root") + } +} + +func TestServiceConfigureGenericErrorDefaultsInvalidArgument(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + configureFunc: func(context.Context, map[string]any) error { return errors.New("decode failed") }, + } + client := newTestClient(t, p) + + _, err := client.Configure(t.Context(), &toolv1.ConfigureRequest{}) + st, ok := status.FromError(err) + if !ok { + t.Fatalf("Configure error is not a *status.Status: %v", err) + } + if st.Code() != codes.InvalidArgument { + t.Errorf("code = %v, want %v", st.Code(), codes.InvalidArgument) + } +} + +func TestServiceInvokeStreamsEvents(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + invokeFunc: func(_ context.Context, call *tool.Call, stream *tool.Stream) error { + if call.ToolName != "read_file" { + t.Errorf("call.ToolName = %q, want %q", call.ToolName, "read_file") + } + if err := stream.Send(tool.NewOutputChunkEvent(tool.OutputStreamStdout, []byte("hello"))); err != nil { + return err + } + return stream.Send(tool.NewResultEvent(map[string]any{"ok": true})) + }, + } + client := newTestClient(t, p) + + args, err := structpb.NewStruct(map[string]any{"path": "a.go"}) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + stream, err := client.Invoke(t.Context(), &toolv1.InvokeRequest{Call: &toolv1.ToolCall{ + Id: "call-1", + ToolName: "read_file", + Arguments: args, + CallContext: &commonv1.CallContext{SessionId: "s1", TurnId: "t1", WorkingDirectory: "/work"}, + }}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + + var events []*toolv1.ToolEvent + for { + resp, err := stream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("stream.Recv: %v", err) + } + events = append(events, resp.GetEvent()) + } + + if len(events) != 2 { + t.Fatalf("got %d events, want 2", len(events)) + } + if events[0].GetOutputChunk() == nil || string(events[0].GetOutputChunk().GetData()) != "hello" { + t.Errorf("events[0] = %v, want output_chunk %q", events[0], "hello") + } + if events[1].GetResult() == nil || !events[1].GetResult().GetPayload().AsMap()["ok"].(bool) { + t.Errorf("events[1] = %v, want result ok=true", events[1]) + } +} + +func TestServiceInvokeCancellationIsNotSurfacedAsError(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + invokeFunc: func(context.Context, *tool.Call, *tool.Stream) error { + // Simulate a Provider that detects cancellation itself and + // returns context.Canceled rather than sending a terminal + // event — README.md#transport--lifecycle: cancellation is + // normal control flow, never surfaced as an application + // error. + return context.Canceled + }, + } + client := newTestClient(t, p) + + stream, err := client.Invoke(t.Context(), &toolv1.InvokeRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "op"}}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + _, err = stream.Recv() + if !errors.Is(err, io.EOF) { + t.Fatalf("stream.Recv() after a context.Canceled Invoke = %v, want io.EOF (a clean close, not a failed RPC)", err) + } +} + +func TestServiceInvokeGenericErrorMapsToUnknown(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + invokeFunc: func(context.Context, *tool.Call, *tool.Stream) error { + return errors.New("provider panic recovered") + }, + } + client := newTestClient(t, p) + + stream, err := client.Invoke(t.Context(), &toolv1.InvokeRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "op"}}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + _, err = stream.Recv() + st, ok := status.FromError(err) + if !ok { + t.Fatalf("stream.Recv() error is not a *status.Status: %v", err) + } + if st.Code() != codes.Internal { + t.Errorf("code = %v, want %v (ErrorCategoryUnknown maps to codes.Internal)", st.Code(), codes.Internal) + } +} + +func TestServiceInvokeInvalidCallRejected(t *testing.T) { + t.Parallel() + + client := newTestClient(t, &fakeProvider{}) + + stream, err := client.Invoke(t.Context(), &toolv1.InvokeRequest{Call: nil}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + _, err = stream.Recv() + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("stream.Recv() code = %v, want %v", status.Code(err), codes.InvalidArgument) + } +} + +func TestServiceInvokeWithoutTerminalEventFails(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + invokeFunc: func(context.Context, *tool.Call, *tool.Stream) error { return nil }, + } + client := newTestClient(t, p) + + stream, err := client.Invoke(t.Context(), &toolv1.InvokeRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "op"}}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + _, err = stream.Recv() + if err == nil { + t.Fatal("stream.Recv(): want an error (provider never sent a terminal event)") + } +} + +func TestServiceInvokeErrorEventTerminates(t *testing.T) { + t.Parallel() + + p := &fakeProvider{ + invokeFunc: func(_ context.Context, _ *tool.Call, stream *tool.Stream) error { + te, err := tool.NewError(tool.ErrorCategoryNotFound, "no such file", false, nil) + if err != nil { + return err + } + return stream.Send(tool.NewErrorEvent(te)) + }, + } + client := newTestClient(t, p) + + stream, err := client.Invoke(t.Context(), &toolv1.InvokeRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "op"}}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + resp, err := stream.Recv() + if err != nil { + t.Fatalf("stream.Recv: %v", err) + } + if resp.GetEvent().GetError() == nil || resp.GetEvent().GetError().GetMessage() != "no such file" { + t.Errorf("event = %v, want error \"no such file\"", resp.GetEvent()) + } + if _, err := stream.Recv(); !errors.Is(err, io.EOF) { + t.Errorf("stream.Recv() after terminal error event = %v, want io.EOF", err) + } +} + +func TestServiceDescribe(t *testing.T) { + t.Parallel() + + client := newTestClient(t, &fakeProvider{}) + + resp, err := client.Describe(t.Context(), &toolv1.DescribeRequest{}) + if err != nil { + t.Fatalf("Describe: %v", err) + } + producer := resp.GetProducer() + if producer.GetName() != "fake-tool" || producer.GetVersion() != "0.0.1" { + t.Errorf("producer = %v, want name=fake-tool version=0.0.1", producer) + } + if producer.GetCategory() != commonv1.Category_CATEGORY_TOOL { + t.Errorf("producer.Category = %v, want CATEGORY_TOOL", producer.GetCategory()) + } +} + +func TestServiceRenderUnimplementedWithoutRenderer(t *testing.T) { + t.Parallel() + + client := newTestClient(t, &fakeProvider{}) + + _, err := client.Render(t.Context(), &toolv1.RenderRequest{}) + if status.Code(err) != codes.Unimplemented { + t.Fatalf("Render() code = %v, want %v", status.Code(err), codes.Unimplemented) + } +} + +func TestServicePreviewUnimplementedWithoutPreviewer(t *testing.T) { + t.Parallel() + + client := newTestClient(t, &fakeProvider{}) + + _, err := client.Preview(t.Context(), &toolv1.PreviewRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "op"}}) + if status.Code(err) != codes.Unimplemented { + t.Fatalf("Preview() code = %v, want %v", status.Code(err), codes.Unimplemented) + } +} + +func TestServiceRenderAndPreview(t *testing.T) { + t.Parallel() + + base := &fakeProvider{} + p := &fakeFullProvider{ + fakeProvider: base, + renderFunc: func(_ context.Context, payload []byte, schemaVersion string) (*renderv1.RenderTree, error) { + if string(payload) != "raw" || schemaVersion != "v1" { + t.Errorf("Render(%q, %q)", payload, schemaVersion) + } + return &renderv1.RenderTree{Root: &renderv1.RenderNode{}}, nil + }, + previewFunc: func(_ context.Context, call *tool.Call) (*renderv1.RenderTree, error) { + if call.ToolName != "edit_file" { + t.Errorf("Preview call.ToolName = %q, want %q", call.ToolName, "edit_file") + } + return &renderv1.RenderTree{Root: &renderv1.RenderNode{}}, nil + }, + } + client := newTestClient(t, p) + + if _, err := client.Render(t.Context(), &toolv1.RenderRequest{Payload: []byte("raw"), SchemaVersion: "v1"}); err != nil { + t.Fatalf("Render: %v", err) + } + if _, err := client.Preview(t.Context(), &toolv1.PreviewRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "edit_file"}}); err != nil { + t.Fatalf("Preview: %v", err) + } +} + +func TestContextWithCallbackRoundTrip(t *testing.T) { + t.Parallel() + + cb := plugin.NewCallback() + ctx := tool.ContextWithCallback(t.Context(), cb) + + got, ok := tool.CallbackFromContext(ctx) + if !ok || got != cb { + t.Errorf("CallbackFromContext = (%v, %v), want (%v, true)", got, ok, cb) + } + + if _, ok := tool.CallbackFromContext(t.Context()); ok { + t.Error("CallbackFromContext on a plain context: want ok=false") + } +} + +func TestServiceInvokeSeesCallback(t *testing.T) { + t.Parallel() + + var sawCallback bool + p := &fakeProvider{ + invokeFunc: func(ctx context.Context, _ *tool.Call, stream *tool.Stream) error { + _, sawCallback = tool.CallbackFromContext(ctx) + return stream.Send(tool.NewResultEvent(nil)) + }, + } + client := newTestClient(t, p) + + stream, err := client.Invoke(t.Context(), &toolv1.InvokeRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "op"}}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + for { + if _, err := stream.Recv(); errors.Is(err, io.EOF) { + break + } else if err != nil { + t.Fatalf("stream.Recv: %v", err) + } + } + if !sawCallback { + t.Error("Provider.Invoke's context did not carry the Service's *plugin.Callback") + } +} diff --git a/pkg/tool/stream.go b/pkg/tool/stream.go new file mode 100644 index 0000000..56de08b --- /dev/null +++ b/pkg/tool/stream.go @@ -0,0 +1,132 @@ +package tool + +import ( + "context" + "errors" + "sync" + + toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" +) + +// Sentinel errors returned by Stream.Send. +var ( + // ErrStreamClosed is returned by Send once a terminal Result or + // Error event has already been sent — the stream contract + // (docs/specifications/tool/protocol.md#invoke: "exactly one of + // result or error MUST close the stream") forbids sending anything + // after that. + ErrStreamClosed = errors.New("tool: invoke stream already closed by a terminal result or error event") + // ErrResultAfterCancel is returned by Send when asked to send a + // success Result event after the stream's context has already been + // cancelled. docs/specifications/tool/protocol.md#invoke: "A plugin + // MUST NOT synthesize a result claiming full success after a + // cancelled operation." — send a partial_result/output_chunk best- + // effort report, or an error event with ErrorCategoryCancelled, + // instead. + ErrResultAfterCancel = errors.New("tool: cannot send a success result after the stream's context was canceled") + // ErrDuplicateExitStatus is returned by Send on a second exit_status + // event within one stream. docs/specifications/tool/data-types.md#toolcall--toolevent--toolresult: + // "exit_status MAY appear at most once." + ErrDuplicateExitStatus = errors.New("tool: exit_status may appear at most once per invoke stream") +) + +// Stream is the cancellation-safe sender a Provider's Invoke uses to emit +// ToolEvents, per docs/specifications/tool/protocol.md#invoke. It enforces +// the parts of the Invoke stream contract that are mechanically checkable +// from the sequence of Send calls alone: +// +// - exactly one of a terminal Result or Error event closes the stream; +// any Send after that returns ErrStreamClosed. +// - exit_status appears at most once; a second one returns +// ErrDuplicateExitStatus. +// - a success Result is refused once the stream's own context has been +// cancelled, so a Provider cannot synthesize a false "succeeded" +// terminal event after cancellation (ErrResultAfterCancel) — send a +// partial_result or an error event with ErrorCategoryCancelled +// instead. +// - output_chunk (and every other event) ordering is preserved because +// Send serializes every call under one mutex rather than writing to +// the underlying gRPC stream directly from more than one goroutine. +// +// What Stream deliberately does NOT enforce: whether exit_status belongs +// on this particular call at all. docs/specifications/tool/protocol.md#invoke +// restricts exit_status to process-backed (exec-family) operations, but +// that fact lives in the *operation's* documentation/convention, not on +// the wire Call or Schema — there is no process_backed field to +// check against. Enforcing "at most once" is this package's chosen, +// mechanically-checkable substitute; whether a given operation should ever +// call NewExitStatusEvent at all remains a Provider-author discipline +// concern, exactly as the spec frames it ("a provider for a non-process- +// backed tool ... MUST NOT emit this"). +type Stream struct { + mu sync.Mutex + grpcStream toolv1.ToolService_InvokeServer + closed bool + exitStatusSent bool +} + +// newStream wraps g for use by a single Invoke call. +func newStream(g toolv1.ToolService_InvokeServer) *Stream { + return &Stream{grpcStream: g} +} + +// Context returns the Invoke call's context — cancelled by the kernel +// closing the gRPC stream (user interrupt, timeout, turn abort). A +// Provider treats this as normal control flow, never as an error +// condition to log. +func (s *Stream) Context() context.Context { + return s.grpcStream.Context() +} + +// Send sends event, enforcing the stream contract documented on Stream. +// Safe for concurrent use; concurrent Send calls serialize rather than +// racing the underlying gRPC stream. +func (s *Stream) Send(event *Event) error { + if event == nil { + return ErrNilEvent + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return ErrStreamClosed + } + + if event.Result != nil { + select { + case <-s.grpcStream.Context().Done(): + return ErrResultAfterCancel + default: + } + } + + if event.ExitStatus != nil { + if s.exitStatusSent { + return ErrDuplicateExitStatus + } + s.exitStatusSent = true + } + + pe, err := toProtoEvent(event) + if err != nil { + return err + } + if err := s.grpcStream.Send(&toolv1.InvokeResponse{Event: pe}); err != nil { + return err + } + + if event.Result != nil || event.Error != nil { + s.closed = true + } + return nil +} + +// closedTerminal reports whether a terminal Result or Error event has +// already been sent — used by server.go to detect a Provider.Invoke that +// returned nil without ever closing the stream. +func (s *Stream) closedTerminal() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.closed +} diff --git a/pkg/tool/stream_test.go b/pkg/tool/stream_test.go new file mode 100644 index 0000000..ca73cae --- /dev/null +++ b/pkg/tool/stream_test.go @@ -0,0 +1,188 @@ +package tool + +import ( + "context" + "errors" + "io" + "sync" + "testing" + + "google.golang.org/grpc/metadata" + + toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" +) + +// fakeInvokeServerStream is a hand-written fake of +// toolv1.ToolService_InvokeServer (grpc.ServerStreamingServer[InvokeResponse]), +// per .claude/rules/go-testing.md's "fakes, not mocking frameworks" rule. +type fakeInvokeServerStream struct { + ctx context.Context + sendErr error + + mu sync.Mutex + sent []*toolv1.InvokeResponse +} + +func newFakeInvokeServerStream(ctx context.Context) *fakeInvokeServerStream { + return &fakeInvokeServerStream{ctx: ctx} +} + +func (f *fakeInvokeServerStream) Send(r *toolv1.InvokeResponse) error { + if f.sendErr != nil { + return f.sendErr + } + f.mu.Lock() + defer f.mu.Unlock() + f.sent = append(f.sent, r) + return nil +} + +func (f *fakeInvokeServerStream) events() []*toolv1.InvokeResponse { + f.mu.Lock() + defer f.mu.Unlock() + return append([]*toolv1.InvokeResponse(nil), f.sent...) +} + +func (f *fakeInvokeServerStream) SetHeader(metadata.MD) error { return nil } +func (f *fakeInvokeServerStream) SendHeader(metadata.MD) error { return nil } +func (f *fakeInvokeServerStream) SetTrailer(metadata.MD) {} +func (f *fakeInvokeServerStream) Context() context.Context { return f.ctx } +func (f *fakeInvokeServerStream) SendMsg(m any) error { + return f.Send(m.(*toolv1.InvokeResponse)) +} +func (f *fakeInvokeServerStream) RecvMsg(any) error { return io.EOF } + +var _ toolv1.ToolService_InvokeServer = (*fakeInvokeServerStream)(nil) + +func TestStreamSendTerminalClosesStream(t *testing.T) { + t.Parallel() + + f := newFakeInvokeServerStream(t.Context()) + s := newStream(f) + + if err := s.Send(NewOutputChunkEvent(OutputStreamStdout, []byte("a"))); err != nil { + t.Fatalf("Send(output_chunk): %v", err) + } + if s.closedTerminal() { + t.Fatal("closedTerminal() = true before any terminal event") + } + + if err := s.Send(NewResultEvent(map[string]any{"ok": true})); err != nil { + t.Fatalf("Send(result): %v", err) + } + if !s.closedTerminal() { + t.Fatal("closedTerminal() = false after a result event") + } + + // A second terminal event — even a different one — after the first + // MUST be rejected: exactly one of result/error closes the stream. + err := s.Send(NewErrorEvent(&Error{Category: ErrorCategoryUnknown, Message: "too late"})) + if !errors.Is(err, ErrStreamClosed) { + t.Fatalf("second terminal Send() error = %v, want wrapping %v", err, ErrStreamClosed) + } + + if got := len(f.events()); got != 2 { + t.Errorf("events sent = %d, want 2 (the rejected send must not reach the wire)", got) + } +} + +func TestStreamSendNilEvent(t *testing.T) { + t.Parallel() + + s := newStream(newFakeInvokeServerStream(t.Context())) + if err := s.Send(nil); !errors.Is(err, ErrNilEvent) { + t.Errorf("Send(nil) error = %v, want wrapping %v", err, ErrNilEvent) + } +} + +func TestStreamDuplicateExitStatusRejected(t *testing.T) { + t.Parallel() + + s := newStream(newFakeInvokeServerStream(t.Context())) + + if err := s.Send(NewExitStatusEvent(0, nil)); err != nil { + t.Fatalf("first exit_status Send(): %v", err) + } + err := s.Send(NewExitStatusEvent(1, nil)) + if !errors.Is(err, ErrDuplicateExitStatus) { + t.Fatalf("second exit_status Send() error = %v, want wrapping %v", err, ErrDuplicateExitStatus) + } +} + +func TestStreamResultAfterCancelRejected(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + f := newFakeInvokeServerStream(ctx) + s := newStream(f) + + cancel() // simulate the kernel closing the stream mid-call. + + err := s.Send(NewResultEvent(map[string]any{"ok": true})) + if !errors.Is(err, ErrResultAfterCancel) { + t.Fatalf("Send(result) after cancel error = %v, want wrapping %v", err, ErrResultAfterCancel) + } + + // A best-effort partial-mutation report MUST still be sendable after + // cancellation — only a synthesized success result is refused. + if err := s.Send(NewPartialResultEvent(map[string]any{"partial": true})); err != nil { + t.Fatalf("Send(partial_result) after cancel: %v", err) + } + if err := s.Send(NewErrorEvent(&Error{Category: ErrorCategoryCancelled, Message: "cancelled"})); err != nil { + t.Fatalf("Send(error, cancelled) after cancel: %v", err) + } + if !s.closedTerminal() { + t.Error("closedTerminal() = false after a cancelled-category error event") + } +} + +func TestStreamSendPropagatesTransportError(t *testing.T) { + t.Parallel() + + wantErr := errors.New("broken pipe") + f := newFakeInvokeServerStream(t.Context()) + f.sendErr = wantErr + s := newStream(f) + + err := s.Send(NewOutputChunkEvent(OutputStreamStdout, []byte("x"))) + if !errors.Is(err, wantErr) { + t.Fatalf("Send() error = %v, want wrapping %v", err, wantErr) + } + if s.closedTerminal() { + t.Error("closedTerminal() = true after a non-terminal event's Send failed") + } +} + +func TestStreamSendPreservesOrdering(t *testing.T) { + t.Parallel() + + f := newFakeInvokeServerStream(t.Context()) + s := newStream(f) + + var wg sync.WaitGroup + const n = 20 + wg.Add(n) + for i := range n { + go func(i int) { + defer wg.Done() + _ = s.Send(NewProgressEvent("step", nil)) + _ = i + }(i) + } + wg.Wait() + + if got := len(f.events()); got != n { + t.Fatalf("events sent = %d, want %d (concurrent Send calls must not race the transport)", got, n) + } +} + +func TestStreamContext(t *testing.T) { + t.Parallel() + + ctx := t.Context() + f := newFakeInvokeServerStream(ctx) + s := newStream(f) + if s.Context() != ctx { + t.Error("Context() did not return the underlying gRPC stream's context") + } +} diff --git a/pkg/tool/tool.go b/pkg/tool/tool.go new file mode 100644 index 0000000..b2232fb --- /dev/null +++ b/pkg/tool/tool.go @@ -0,0 +1,377 @@ +package tool + +import ( + "context" + "time" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" + schemav1 "github.com/pluggableharness/agent/pkg/schema/proto/v1" +) + +// Kind classifies whether an operation is gated behind the plan/apply +// approval gate, executes freely, or blocks the current turn for human +// input — see docs/specifications/tool/protocol.md#getschema and +// docs/specifications/tool/protocol.md#kind-interactive. One of the six +// types pkg/slashcommand reuses verbatim; see doc.go. +type Kind int + +const ( + // KindUnspecified is the zero value. Never valid for a real + // operation — its presence means an author forgot to set Kind. + KindUnspecified Kind = iota + // KindResource is a mutating operation, gated behind the + // plan/apply approval gate. + KindResource + // KindDataSource is a read-only operation. Executes freely, + // subject only to the policy precheck. + KindDataSource + // KindInteractive blocks the current turn for human input and + // produces no state mutation of its own — the human's answer becomes + // the result. + KindInteractive +) + +// String returns k's wire-name-derived lowercase form, e.g. "data_source". +func (k Kind) String() string { + switch k { + case KindUnspecified: + return "unspecified" + case KindResource: + return "resource" + case KindDataSource: + return "data_source" + case KindInteractive: + return "interactive" + default: + return "unknown" + } +} + +// RiskClass classifies an operation's blast radius, orthogonal to +// Kind: kind determines whether the plan/apply gate applies at all, +// risk determines how significant the gated (or inherently ungated) +// action is — see docs/specifications/tool/data-types.md#riskclass. One +// of the six types pkg/slashcommand reuses verbatim; see doc.go. +type RiskClass int + +const ( + // RiskClassUnspecified is the zero value. Never valid for a real + // operation. + RiskClassUnspecified RiskClass = iota + // RiskClassReadOnly is inherently unable to mutate anything the + // plugin controls. MUST be used for KindDataSource and + // KindInteractive alike. + RiskClassReadOnly + // RiskClassLow is a resource operation with narrow, easily-reversible + // blast radius, e.g. a write to a scratch path. + RiskClassLow + // RiskClassModerate is a resource operation with real but bounded + // blast radius, e.g. editing a tracked source file. + RiskClassModerate + // RiskClassHigh is a resource operation with broad or + // hard-to-predict blast radius, e.g. arbitrary shell execution. + RiskClassHigh + // RiskClassCritical is a resource operation capable of irreversible + // or wide-blast-radius action, e.g. `rm -rf`, a force-push, or + // spawning a sub-agent with further unattended write access. + RiskClassCritical +) + +// String returns r's wire-name-derived lowercase form, e.g. "read_only". +func (r RiskClass) String() string { + switch r { + case RiskClassUnspecified: + return "unspecified" + case RiskClassReadOnly: + return "read_only" + case RiskClassLow: + return "low" + case RiskClassModerate: + return "moderate" + case RiskClassHigh: + return "high" + case RiskClassCritical: + return "critical" + default: + return "unknown" + } +} + +// ConcurrencySpec declares whether this operation's Invoke calls may run +// concurrently against the same provider process, per +// docs/specifications/tool/data-types.md#concurrencyspec. One of the six +// types pkg/slashcommand reuses verbatim; see doc.go. +type ConcurrencySpec struct { + // Safe is MUST-set for every operation except KindInteractive. + // false (the zero value) means the kernel MUST NOT run any other + // Invoke call against this provider process concurrently with this + // one — a coarse, provider-wide lock. true means concurrent Invoke + // calls against this provider are generally safe. + Safe bool + // KeyFields is MAY, only meaningful when Safe is true. Names of this + // operation's input_schema fields whose value(s) form a + // serialization key; the kernel serializes calls sharing an + // identical key while freely parallelizing calls with distinct keys. + // Omitting KeyFields under Safe == true asserts that no two calls to + // this operation can ever conflict — a strong claim, true for e.g. + // web_search, false for e.g. write_file. + KeyFields []string +} + +// OutputStream distinguishes which underlying stream an output chunk came +// from. One of the six types pkg/slashcommand reuses verbatim; see doc.go. +type OutputStream int + +const ( + // OutputStreamUnspecified is the zero value. Never valid for a real + // chunk. + OutputStreamUnspecified OutputStream = iota + // OutputStreamStdout is standard output. + OutputStreamStdout + // OutputStreamStderr is standard error. + OutputStreamStderr +) + +// String returns s's wire-name-derived lowercase form, e.g. "stdout". +func (s OutputStream) String() string { + switch s { + case OutputStreamUnspecified: + return "unspecified" + case OutputStreamStdout: + return "stdout" + case OutputStreamStderr: + return "stderr" + default: + return "unknown" + } +} + +// Result is the terminal, successful outcome of an Invoke call, per +// docs/specifications/tool/data-types.md#toolcall--toolevent--toolresult. +// Payload MUST conform to the operation's declared Schema.OutputSchema +// — the kernel validates this strictly and rejects a non-conforming +// payload rather than passing it through to history. One of the six types +// pkg/slashcommand reuses verbatim; see doc.go. Deliberately holds nothing +// Call-specific (no call ID, no tool name) so it reuses cleanly for a +// slash command's own direct-invoke result. +type Result struct { + // Payload is the already-decoded JSON result payload. + Payload map[string]any +} + +// Schema declares one operation a Provider exposes, per +// docs/specifications/tool/protocol.md#getschema. +type Schema struct { + // Name MUST be unique within this provider's namespace, e.g. + // "read_file". + Name string + // Kind MUST be set — drives the plan/apply gate. + Kind Kind + // Risk MUST be set — see RiskClass. MUST be RiskClassReadOnly for + // KindDataSource and KindInteractive alike; MUST be one of + // low/moderate/high/critical for KindResource. + Risk RiskClass + // Description MUST be set — shown to the model for tool selection + // and in plan diffs. + Description string + // InputSchema MUST be set — the common JSON-Schema subset (built with + // pkg/schema) describing Call.Arguments's shape for this + // operation. + InputSchema *schemav1.Schema + // OutputSchema MUST be set — the common JSON-Schema subset describing + // Result.Payload's shape for this operation. + OutputSchema *schemav1.Schema + // Streaming MUST be set — true if Invoke may emit intermediate + // events (output_chunk, progress, partial_result) before the + // terminal event; false if Invoke always emits exactly one terminal + // event with no lead-up. + Streaming bool + // Concurrency MUST be set for every kind except KindInteractive, + // for which it MUST be nil — see ConcurrencySpec. + Concurrency *ConcurrencySpec + // DefaultTimeout SHOULD be set — the deadline the kernel applies to + // Invoke for this operation absent an agent.hcl override. The zero + // value means unset: the kernel's own global default applies + // instead. + DefaultTimeout time.Duration + // Idempotent is true iff re-running this operation with identical + // arguments cannot produce a different end state than running it + // once. Gates whether the kernel MAY auto-retry a retryable + // Error for a KindResource operation — + // docs/specifications/tool/conformance.md's retry interaction. + // KindDataSource operations are implicitly safe to retry + // regardless of this field. + Idempotent bool +} + +// Call is one request to execute an operation, per +// docs/specifications/tool/data-types.md#toolcall--toolevent--toolresult. +type Call struct { + // ID is kernel-assigned; echoed in every Event for this call. + ID string + // ToolName matches a Schema.Name from this provider's Schema. + ToolName string + // Arguments is already-parsed JSON conforming to that operation's + // InputSchema. + Arguments map[string]any + // CallContext is always set by the kernel. Its WorkingDirectory is + // the cwd a process-backed operation (exec/bash, read_file, and + // similarly-shaped tools) MUST resolve any relative-path argument + // against — without it, those tools have no defined cwd and are + // unusable. Its SessionId/TurnId are what a Provider echoes back on + // its own kernel-callback Emit/Log calls for correlation. See + // docs/specifications/tool/protocol.md#invoke. + CallContext *commonv1.CallContext +} + +// OutputChunkEvent carries one slice of raw stdout/stderr-shaped output +// from a process-backed operation. +type OutputChunkEvent struct { + Stream OutputStream + Data []byte +} + +// ProgressEvent carries a human-readable status update for a long-running +// call. +type ProgressEvent struct { + Message string + // FractionComplete is how far through the operation this call is, in + // [0.0, 1.0]. nil means the provider cannot estimate completion + // fraction. + FractionComplete *float64 +} + +// PartialResultEvent carries incremental structured output before the +// terminal result, e.g. search hits as they're found. +type PartialResultEvent struct { + Payload map[string]any +} + +// ExitStatusEvent carries a process-backed operation's child process exit +// information. exec-family tools only — a Provider for a non-process- +// backed tool (file read, grep, web fetch) MUST NOT emit this. At most one +// per Invoke stream. +type ExitStatusEvent struct { + ExitCode int32 + // Signal is the signal that terminated the child process, if any. + // nil means the process exited normally. + Signal *string +} + +// Event is one message a Provider's Invoke sends via *Stream, per +// docs/specifications/tool/data-types.md#toolcall--toolevent--toolresult. +// Exactly one field is set; construct one with NewOutputChunkEvent, +// NewProgressEvent, NewPartialResultEvent, NewExitStatusEvent, +// NewResultEvent, or NewErrorEvent rather than a struct literal — see +// stream.go for the ordering, cardinality, and terminal-event contract +// *Stream.Send enforces. +type Event struct { + OutputChunk *OutputChunkEvent + Progress *ProgressEvent + PartialResult *PartialResultEvent + ExitStatus *ExitStatusEvent + Result *Result + Error *Error +} + +// NewOutputChunkEvent builds a Event carrying one output chunk. +func NewOutputChunkEvent(stream OutputStream, data []byte) *Event { + return &Event{OutputChunk: &OutputChunkEvent{Stream: stream, Data: data}} +} + +// NewProgressEvent builds a Event carrying a progress update. +// fractionComplete may be nil. +func NewProgressEvent(message string, fractionComplete *float64) *Event { + return &Event{Progress: &ProgressEvent{Message: message, FractionComplete: fractionComplete}} +} + +// NewPartialResultEvent builds a Event carrying incremental structured +// output. +func NewPartialResultEvent(payload map[string]any) *Event { + return &Event{PartialResult: &PartialResultEvent{Payload: payload}} +} + +// NewExitStatusEvent builds a Event carrying a child process's exit +// status. signal may be nil. +func NewExitStatusEvent(exitCode int32, signal *string) *Event { + return &Event{ExitStatus: &ExitStatusEvent{ExitCode: exitCode, Signal: signal}} +} + +// NewResultEvent builds a Event carrying the terminal, successful +// result. +func NewResultEvent(payload map[string]any) *Event { + return &Event{Result: &Result{Payload: payload}} +} + +// NewErrorEvent builds a Event carrying the terminal, failed result. +func NewErrorEvent(err *Error) *Event { + return &Event{Error: err} +} + +// Provider is the interface a tool plugin author implements; NewService +// adapts it onto the generated toolv1.ToolServiceServer. +type Provider interface { + // Schema returns the Schema for every operation this plugin + // exposes, per docs/specifications/tool/protocol.md#getschema. MUST + // be cheaply re-queryable and MUST NOT make a network call. + Schema(ctx context.Context) ([]*Schema, error) + // Configure decodes and validates this provider's agent.hcl block, + // already decoded from JSON into config. MUST reject with an error + // on a missing required field rather than deferring failure to the + // first Invoke. A returned *Error is surfaced with its own + // category/message; any other error defaults to + // ErrorCategoryInvalidArguments. + Configure(ctx context.Context, config map[string]any) error + // Invoke executes call, sending zero or more non-terminal events and + // exactly one terminal event (built with NewResultEvent or + // NewErrorEvent) via stream before returning. Returning a nil error + // without having sent a terminal event is a Provider bug the adapter + // surfaces as a failed RPC. See stream.go for the full contract. + Invoke(ctx context.Context, call *Call, stream *Stream) error +} + +// Renderer is an optional interface a Provider MAY additionally implement +// to render a previously-emitted opaque payload as a RenderTree, per +// docs/specifications/tool/protocol.md#render. If a Provider does not +// implement Renderer, the kernel falls back to its generic default +// (pretty-printed JSON payload). +type Renderer interface { + Render(ctx context.Context, payload []byte, schemaVersion string) (*renderv1.RenderTree, error) +} + +// Previewer is an optional interface a Provider MAY additionally implement +// to describe, without executing, what Invoke(call) would do, per +// docs/specifications/tool/protocol.md#preview. Producing a preview MUST +// NOT mutate anything and MUST be side-effect-free; a Provider unable to +// satisfy that for a given operation MUST NOT implement Previewer for it. +// If a Provider does not implement Previewer, a kernel falls back to +// showing the call's raw arguments in the plan/apply gate's permission UI. +type Previewer interface { + Preview(ctx context.Context, call *Call) (*renderv1.RenderTree, error) +} + +// ConfigSchemaProvider is an optional interface a Provider MAY implement +// to advertise the ConfigSchema (built with pkg/config) the kernel decodes +// its agent.hcl provider block against before ever calling Configure. A +// Provider that takes no configuration simply does not implement this +// interface. +type ConfigSchemaProvider interface { + ConfigSchema() (*configv1.ConfigSchema, error) +} + +// SlashCommandProvider is an optional interface a Provider MAY implement +// to contribute prompt-expansion slash commands to its GetSchema response, +// per docs/specifications/tool/protocol.md#getschema. +type SlashCommandProvider interface { + SlashCommands() []*commonv1.PromptExpansionSpec +} + +// HookPointProvider is an optional interface a Provider MAY implement to +// advertise which of the eight dispatchable hook points its +// HookSubscriberService subscribes to, per +// docs/specifications/tool/protocol.md#getschema. +type HookPointProvider interface { + SupportedHookPoints() []commonv1.HookPoint +} diff --git a/pkg/tool/tool_test.go b/pkg/tool/tool_test.go new file mode 100644 index 0000000..692b027 --- /dev/null +++ b/pkg/tool/tool_test.go @@ -0,0 +1,167 @@ +package tool_test + +import ( + "testing" + + "github.com/pluggableharness/agent/pkg/tool" +) + +func TestKindString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + kind tool.Kind + want string + }{ + {"unspecified", tool.KindUnspecified, "unspecified"}, + {"resource", tool.KindResource, "resource"}, + {"data_source", tool.KindDataSource, "data_source"}, + {"interactive", tool.KindInteractive, "interactive"}, + {"out of range", tool.Kind(99), "unknown"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.kind.String(); got != tt.want { + t.Errorf("Kind(%d).String() = %q, want %q", tt.kind, got, tt.want) + } + }) + } +} + +func TestRiskClassString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + risk tool.RiskClass + want string + }{ + {"unspecified", tool.RiskClassUnspecified, "unspecified"}, + {"read_only", tool.RiskClassReadOnly, "read_only"}, + {"low", tool.RiskClassLow, "low"}, + {"moderate", tool.RiskClassModerate, "moderate"}, + {"high", tool.RiskClassHigh, "high"}, + {"critical", tool.RiskClassCritical, "critical"}, + {"out of range", tool.RiskClass(99), "unknown"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.risk.String(); got != tt.want { + t.Errorf("RiskClass(%d).String() = %q, want %q", tt.risk, got, tt.want) + } + }) + } +} + +func TestOutputStreamString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + stream tool.OutputStream + want string + }{ + {"unspecified", tool.OutputStreamUnspecified, "unspecified"}, + {"stdout", tool.OutputStreamStdout, "stdout"}, + {"stderr", tool.OutputStreamStderr, "stderr"}, + {"out of range", tool.OutputStream(99), "unknown"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.stream.String(); got != tt.want { + t.Errorf("OutputStream(%d).String() = %q, want %q", tt.stream, got, tt.want) + } + }) + } +} + +func TestNewOutputChunkEvent(t *testing.T) { + t.Parallel() + + ev := tool.NewOutputChunkEvent(tool.OutputStreamStdout, []byte("hello")) + if ev.OutputChunk == nil { + t.Fatal("NewOutputChunkEvent: OutputChunk field is nil") + } + if ev.OutputChunk.Stream != tool.OutputStreamStdout { + t.Errorf("OutputChunk.Stream = %v, want %v", ev.OutputChunk.Stream, tool.OutputStreamStdout) + } + if string(ev.OutputChunk.Data) != "hello" { + t.Errorf("OutputChunk.Data = %q, want %q", ev.OutputChunk.Data, "hello") + } + if ev.Progress != nil || ev.PartialResult != nil || ev.ExitStatus != nil || ev.Result != nil || ev.Error != nil { + t.Errorf("NewOutputChunkEvent set more than one field: %+v", ev) + } +} + +func TestNewProgressEvent(t *testing.T) { + t.Parallel() + + frac := 0.5 + ev := tool.NewProgressEvent("halfway", &frac) + if ev.Progress == nil { + t.Fatal("NewProgressEvent: Progress field is nil") + } + if ev.Progress.Message != "halfway" { + t.Errorf("Progress.Message = %q, want %q", ev.Progress.Message, "halfway") + } + if ev.Progress.FractionComplete == nil || *ev.Progress.FractionComplete != 0.5 { + t.Errorf("Progress.FractionComplete = %v, want 0.5", ev.Progress.FractionComplete) + } +} + +func TestNewPartialResultEvent(t *testing.T) { + t.Parallel() + + ev := tool.NewPartialResultEvent(map[string]any{"hit": "a.go"}) + if ev.PartialResult == nil { + t.Fatal("NewPartialResultEvent: PartialResult field is nil") + } + if ev.PartialResult.Payload["hit"] != "a.go" { + t.Errorf("PartialResult.Payload = %v, want hit=a.go", ev.PartialResult.Payload) + } +} + +func TestNewExitStatusEvent(t *testing.T) { + t.Parallel() + + sig := "SIGTERM" + ev := tool.NewExitStatusEvent(1, &sig) + if ev.ExitStatus == nil { + t.Fatal("NewExitStatusEvent: ExitStatus field is nil") + } + if ev.ExitStatus.ExitCode != 1 { + t.Errorf("ExitStatus.ExitCode = %d, want 1", ev.ExitStatus.ExitCode) + } + if ev.ExitStatus.Signal == nil || *ev.ExitStatus.Signal != "SIGTERM" { + t.Errorf("ExitStatus.Signal = %v, want SIGTERM", ev.ExitStatus.Signal) + } +} + +func TestNewResultEvent(t *testing.T) { + t.Parallel() + + ev := tool.NewResultEvent(map[string]any{"ok": true}) + if ev.Result == nil { + t.Fatal("NewResultEvent: Result field is nil") + } + if ev.Result.Payload["ok"] != true { + t.Errorf("Result.Payload = %v, want ok=true", ev.Result.Payload) + } +} + +func TestNewErrorEvent(t *testing.T) { + t.Parallel() + + te, err := tool.NewError(tool.ErrorCategoryNotFound, "not found", false, nil) + if err != nil { + t.Fatalf("NewError: %v", err) + } + ev := tool.NewErrorEvent(te) + if ev.Error != te { + t.Errorf("NewErrorEvent: Error field = %v, want %v", ev.Error, te) + } +} diff --git a/pkg/trace/proto/v1/types.pb.go b/pkg/trace/proto/v1/types.pb.go new file mode 100644 index 0000000..300df42 --- /dev/null +++ b/pkg/trace/proto/v1/types.pb.go @@ -0,0 +1,695 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/trace/v1/types.proto + +// Package pluggableharness.trace.v1 defines the wire shape of one relayed +// trace span, described in specifications/observability.md and consumed by +// pluggableharness.kernel.v1's ExportSpans RPC (KernelCallbackService). +// This is a minimal, purpose-built mirror of OTel's own span model — not a +// re-export of any upstream OTel proto package — so this project's wire +// contract stays self-contained and versioned under this series' own +// buf-breaking guarantees rather than an external schema's. A Span here is +// relayed, never re-created through the kernel's own tracer +// (observability.md#the-relay-model): the kernel forwards it to a +// collector essentially unchanged, so every identity/timing field below is +// authored by the plugin's own OTel SDK and MUST reach the kernel exactly +// as that SDK produced it. + +package tracev1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// SpanKind mirrors OTel's own span-kind taxonomy — which side of a call a +// span represents, when that's meaningful. +type SpanKind int32 + +const ( + // Zero value. Never valid on the wire; its presence means a caller + // forgot to set the field. + SpanKind_SPAN_KIND_UNSPECIFIED SpanKind = 0 + // The default: an operation internal to the reporting process, with no + // remote counterpart. + SpanKind_SPAN_KIND_INTERNAL SpanKind = 1 + // The receiving side of a synchronous remote call. + SpanKind_SPAN_KIND_SERVER SpanKind = 2 + // The calling side of a synchronous remote call. + SpanKind_SPAN_KIND_CLIENT SpanKind = 3 + // The initiating side of an asynchronous message. + SpanKind_SPAN_KIND_PRODUCER SpanKind = 4 + // The receiving side of an asynchronous message. + SpanKind_SPAN_KIND_CONSUMER SpanKind = 5 +) + +// Enum value maps for SpanKind. +var ( + SpanKind_name = map[int32]string{ + 0: "SPAN_KIND_UNSPECIFIED", + 1: "SPAN_KIND_INTERNAL", + 2: "SPAN_KIND_SERVER", + 3: "SPAN_KIND_CLIENT", + 4: "SPAN_KIND_PRODUCER", + 5: "SPAN_KIND_CONSUMER", + } + SpanKind_value = map[string]int32{ + "SPAN_KIND_UNSPECIFIED": 0, + "SPAN_KIND_INTERNAL": 1, + "SPAN_KIND_SERVER": 2, + "SPAN_KIND_CLIENT": 3, + "SPAN_KIND_PRODUCER": 4, + "SPAN_KIND_CONSUMER": 5, + } +) + +func (x SpanKind) Enum() *SpanKind { + p := new(SpanKind) + *p = x + return p +} + +func (x SpanKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SpanKind) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_trace_v1_types_proto_enumTypes[0].Descriptor() +} + +func (SpanKind) Type() protoreflect.EnumType { + return &file_pluggableharness_trace_v1_types_proto_enumTypes[0] +} + +func (x SpanKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SpanKind.Descriptor instead. +func (SpanKind) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_trace_v1_types_proto_rawDescGZIP(), []int{0} +} + +// StatusCode is a span's outcome, mirroring OTel's own three-value status +// model. +type StatusCode int32 + +const ( + // Zero value. Never valid on the wire; its presence means a caller + // forgot to set the field. + StatusCode_STATUS_CODE_UNSPECIFIED StatusCode = 0 + // The operation completed successfully, or no status was explicitly set + // (OTel's own "Unset" reads as success for export purposes). + StatusCode_STATUS_CODE_OK StatusCode = 1 + // The operation failed. + StatusCode_STATUS_CODE_ERROR StatusCode = 2 +) + +// Enum value maps for StatusCode. +var ( + StatusCode_name = map[int32]string{ + 0: "STATUS_CODE_UNSPECIFIED", + 1: "STATUS_CODE_OK", + 2: "STATUS_CODE_ERROR", + } + StatusCode_value = map[string]int32{ + "STATUS_CODE_UNSPECIFIED": 0, + "STATUS_CODE_OK": 1, + "STATUS_CODE_ERROR": 2, + } +) + +func (x StatusCode) Enum() *StatusCode { + p := new(StatusCode) + *p = x + return p +} + +func (x StatusCode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (StatusCode) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_trace_v1_types_proto_enumTypes[1].Descriptor() +} + +func (StatusCode) Type() protoreflect.EnumType { + return &file_pluggableharness_trace_v1_types_proto_enumTypes[1] +} + +func (x StatusCode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use StatusCode.Descriptor instead. +func (StatusCode) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_trace_v1_types_proto_rawDescGZIP(), []int{1} +} + +// Status is a span's terminal outcome. +type Status struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The outcome. MUST be set. + Code StatusCode `protobuf:"varint,1,opt,name=code,proto3,enum=pluggableharness.trace.v1.StatusCode" json:"code,omitempty"` + // A human-readable description of the status, meaningful only when + // code == STATUS_CODE_ERROR. MAY be empty. + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Status) Reset() { + *x = Status{} + mi := &file_pluggableharness_trace_v1_types_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Status) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Status) ProtoMessage() {} + +func (x *Status) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_trace_v1_types_proto_msgTypes[0] + 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 Status.ProtoReflect.Descriptor instead. +func (*Status) Descriptor() ([]byte, []int) { + return file_pluggableharness_trace_v1_types_proto_rawDescGZIP(), []int{0} +} + +func (x *Status) GetCode() StatusCode { + if x != nil { + return x.Code + } + return StatusCode_STATUS_CODE_UNSPECIFIED +} + +func (x *Status) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +// InstrumentationScope identifies the tracer that produced a Span, +// mirroring OTel's own instrumentation-scope concept (the tracer's own +// name/version, distinct from the plugin's ProducerRef, which the kernel +// attaches server-side at export time rather than reading from the span). +type InstrumentationScope struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The tracer's name, e.g. "github.com/pluggableharness/agent/plugin". MUST be set. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // The tracer's version. MAY be empty. + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstrumentationScope) Reset() { + *x = InstrumentationScope{} + mi := &file_pluggableharness_trace_v1_types_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstrumentationScope) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstrumentationScope) ProtoMessage() {} + +func (x *InstrumentationScope) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_trace_v1_types_proto_msgTypes[1] + 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 InstrumentationScope.ProtoReflect.Descriptor instead. +func (*InstrumentationScope) Descriptor() ([]byte, []int) { + return file_pluggableharness_trace_v1_types_proto_rawDescGZIP(), []int{1} +} + +func (x *InstrumentationScope) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *InstrumentationScope) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +// SpanEvent is a timestamped annotation attached to a Span, mirroring +// OTel's own span-event model (e.g. an exception recorded mid-span). +type SpanEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The event's name. MUST be set. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // When the event occurred. MUST be set. + Time *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=time,proto3" json:"time,omitempty"` + // The event's attributes. A Struct because the attribute set is + // genuinely open-ended per call site (see .claude/rules/proto.md's + // Struct carve-out). MAY be empty. + Attributes *structpb.Struct `protobuf:"bytes,3,opt,name=attributes,proto3" json:"attributes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SpanEvent) Reset() { + *x = SpanEvent{} + mi := &file_pluggableharness_trace_v1_types_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SpanEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SpanEvent) ProtoMessage() {} + +func (x *SpanEvent) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_trace_v1_types_proto_msgTypes[2] + 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 SpanEvent.ProtoReflect.Descriptor instead. +func (*SpanEvent) Descriptor() ([]byte, []int) { + return file_pluggableharness_trace_v1_types_proto_rawDescGZIP(), []int{2} +} + +func (x *SpanEvent) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *SpanEvent) GetTime() *timestamppb.Timestamp { + if x != nil { + return x.Time + } + return nil +} + +func (x *SpanEvent) GetAttributes() *structpb.Struct { + if x != nil { + return x.Attributes + } + return nil +} + +// SpanLink references another span this one is causally related to +// without being its parent, mirroring OTel's own span-link model (e.g. a +// batched operation linking back to each request that fed it). +type SpanLink struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The linked span's trace id: 32-character lowercase hex, the W3C + // trace-context trace-id format. MUST be set. + TraceId string `protobuf:"bytes,1,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"` + // The linked span's span id: 16-character lowercase hex, the W3C + // trace-context span-id format. MUST be set. + SpanId string `protobuf:"bytes,2,opt,name=span_id,json=spanId,proto3" json:"span_id,omitempty"` + // The link's attributes. A Struct for the same reason as SpanEvent's + // attributes above (.claude/rules/proto.md's Struct carve-out). MAY be + // empty. + Attributes *structpb.Struct `protobuf:"bytes,3,opt,name=attributes,proto3" json:"attributes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SpanLink) Reset() { + *x = SpanLink{} + mi := &file_pluggableharness_trace_v1_types_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SpanLink) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SpanLink) ProtoMessage() {} + +func (x *SpanLink) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_trace_v1_types_proto_msgTypes[3] + 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 SpanLink.ProtoReflect.Descriptor instead. +func (*SpanLink) Descriptor() ([]byte, []int) { + return file_pluggableharness_trace_v1_types_proto_rawDescGZIP(), []int{3} +} + +func (x *SpanLink) GetTraceId() string { + if x != nil { + return x.TraceId + } + return "" +} + +func (x *SpanLink) GetSpanId() string { + if x != nil { + return x.SpanId + } + return "" +} + +func (x *SpanLink) GetAttributes() *structpb.Struct { + if x != nil { + return x.Attributes + } + return nil +} + +// Span is one completed trace span, exactly as the reporting plugin's own +// OTel SDK produced it. The kernel MUST NOT alter trace_id, span_id, +// parent_span_id, start_time, or end_time before relaying this to a +// collector (observability.md#span-relay-is-transparent) — doing so would +// silently sever this span from the parent/child relationships it already +// had within the plugin's own process. +type Span struct { + state protoimpl.MessageState `protogen:"open.v1"` + // This span's trace id: 32-character lowercase hex, the W3C + // trace-context trace-id format. MUST be set. + TraceId string `protobuf:"bytes,1,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"` + // This span's own id: 16-character lowercase hex, the W3C trace-context + // span-id format. MUST be set. + SpanId string `protobuf:"bytes,2,opt,name=span_id,json=spanId,proto3" json:"span_id,omitempty"` + // The parent span's id, same format as span_id. Absent for a root span. + ParentSpanId *string `protobuf:"bytes,3,opt,name=parent_span_id,json=parentSpanId,proto3,oneof" json:"parent_span_id,omitempty"` + // The span's name. MUST be set. + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` + // The span's kind. MUST be set. + Kind SpanKind `protobuf:"varint,5,opt,name=kind,proto3,enum=pluggableharness.trace.v1.SpanKind" json:"kind,omitempty"` + // When the span started. MUST be set. + StartTime *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=start_time,json=startTime,proto3" json:"start_time,omitempty"` + // When the span ended. MUST be set. + EndTime *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=end_time,json=endTime,proto3" json:"end_time,omitempty"` + // The span's terminal status. MUST be set. + Status *Status `protobuf:"bytes,8,opt,name=status,proto3" json:"status,omitempty"` + // The span's attributes. A Struct because the attribute set is + // genuinely open-ended per call site (see .claude/rules/proto.md's + // Struct carve-out) — unlike a metric.v1.MetricRecord's attributes, a + // span's attributes are never cardinality-bounded by the kernel + // (observability.md#the-tracing-metrics-asymmetry). MAY be empty. + Attributes *structpb.Struct `protobuf:"bytes,9,opt,name=attributes,proto3" json:"attributes,omitempty"` + // Timestamped annotations recorded during the span's lifetime, in + // occurrence order. MAY be empty. + Events []*SpanEvent `protobuf:"bytes,10,rep,name=events,proto3" json:"events,omitempty"` + // Other spans this one is causally related to without being their + // parent. MAY be empty. + Links []*SpanLink `protobuf:"bytes,11,rep,name=links,proto3" json:"links,omitempty"` + // The tracer that produced this span. MUST be set. + Scope *InstrumentationScope `protobuf:"bytes,12,opt,name=scope,proto3" json:"scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Span) Reset() { + *x = Span{} + mi := &file_pluggableharness_trace_v1_types_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Span) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Span) ProtoMessage() {} + +func (x *Span) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_trace_v1_types_proto_msgTypes[4] + 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 Span.ProtoReflect.Descriptor instead. +func (*Span) Descriptor() ([]byte, []int) { + return file_pluggableharness_trace_v1_types_proto_rawDescGZIP(), []int{4} +} + +func (x *Span) GetTraceId() string { + if x != nil { + return x.TraceId + } + return "" +} + +func (x *Span) GetSpanId() string { + if x != nil { + return x.SpanId + } + return "" +} + +func (x *Span) GetParentSpanId() string { + if x != nil && x.ParentSpanId != nil { + return *x.ParentSpanId + } + return "" +} + +func (x *Span) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Span) GetKind() SpanKind { + if x != nil { + return x.Kind + } + return SpanKind_SPAN_KIND_UNSPECIFIED +} + +func (x *Span) GetStartTime() *timestamppb.Timestamp { + if x != nil { + return x.StartTime + } + return nil +} + +func (x *Span) GetEndTime() *timestamppb.Timestamp { + if x != nil { + return x.EndTime + } + return nil +} + +func (x *Span) GetStatus() *Status { + if x != nil { + return x.Status + } + return nil +} + +func (x *Span) GetAttributes() *structpb.Struct { + if x != nil { + return x.Attributes + } + return nil +} + +func (x *Span) GetEvents() []*SpanEvent { + if x != nil { + return x.Events + } + return nil +} + +func (x *Span) GetLinks() []*SpanLink { + if x != nil { + return x.Links + } + return nil +} + +func (x *Span) GetScope() *InstrumentationScope { + if x != nil { + return x.Scope + } + return nil +} + +var File_pluggableharness_trace_v1_types_proto protoreflect.FileDescriptor + +const file_pluggableharness_trace_v1_types_proto_rawDesc = "" + + "\n" + + "%pluggableharness/trace/v1/types.proto\x12\x19pluggableharness.trace.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"]\n" + + "\x06Status\x129\n" + + "\x04code\x18\x01 \x01(\x0e2%.pluggableharness.trace.v1.StatusCodeR\x04code\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"D\n" + + "\x14InstrumentationScope\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\aversion\x18\x02 \x01(\tR\aversion\"\x88\x01\n" + + "\tSpanEvent\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12.\n" + + "\x04time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x04time\x127\n" + + "\n" + + "attributes\x18\x03 \x01(\v2\x17.google.protobuf.StructR\n" + + "attributes\"w\n" + + "\bSpanLink\x12\x19\n" + + "\btrace_id\x18\x01 \x01(\tR\atraceId\x12\x17\n" + + "\aspan_id\x18\x02 \x01(\tR\x06spanId\x127\n" + + "\n" + + "attributes\x18\x03 \x01(\v2\x17.google.protobuf.StructR\n" + + "attributes\"\xeb\x04\n" + + "\x04Span\x12\x19\n" + + "\btrace_id\x18\x01 \x01(\tR\atraceId\x12\x17\n" + + "\aspan_id\x18\x02 \x01(\tR\x06spanId\x12)\n" + + "\x0eparent_span_id\x18\x03 \x01(\tH\x00R\fparentSpanId\x88\x01\x01\x12\x12\n" + + "\x04name\x18\x04 \x01(\tR\x04name\x127\n" + + "\x04kind\x18\x05 \x01(\x0e2#.pluggableharness.trace.v1.SpanKindR\x04kind\x129\n" + + "\n" + + "start_time\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tstartTime\x125\n" + + "\bend_time\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\aendTime\x129\n" + + "\x06status\x18\b \x01(\v2!.pluggableharness.trace.v1.StatusR\x06status\x127\n" + + "\n" + + "attributes\x18\t \x01(\v2\x17.google.protobuf.StructR\n" + + "attributes\x12<\n" + + "\x06events\x18\n" + + " \x03(\v2$.pluggableharness.trace.v1.SpanEventR\x06events\x129\n" + + "\x05links\x18\v \x03(\v2#.pluggableharness.trace.v1.SpanLinkR\x05links\x12E\n" + + "\x05scope\x18\f \x01(\v2/.pluggableharness.trace.v1.InstrumentationScopeR\x05scopeB\x11\n" + + "\x0f_parent_span_id*\x99\x01\n" + + "\bSpanKind\x12\x19\n" + + "\x15SPAN_KIND_UNSPECIFIED\x10\x00\x12\x16\n" + + "\x12SPAN_KIND_INTERNAL\x10\x01\x12\x14\n" + + "\x10SPAN_KIND_SERVER\x10\x02\x12\x14\n" + + "\x10SPAN_KIND_CLIENT\x10\x03\x12\x16\n" + + "\x12SPAN_KIND_PRODUCER\x10\x04\x12\x16\n" + + "\x12SPAN_KIND_CONSUMER\x10\x05*T\n" + + "\n" + + "StatusCode\x12\x1b\n" + + "\x17STATUS_CODE_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eSTATUS_CODE_OK\x10\x01\x12\x15\n" + + "\x11STATUS_CODE_ERROR\x10\x02B>Z pluggableharness.trace.v1.StatusCode + 7, // 1: pluggableharness.trace.v1.SpanEvent.time:type_name -> google.protobuf.Timestamp + 8, // 2: pluggableharness.trace.v1.SpanEvent.attributes:type_name -> google.protobuf.Struct + 8, // 3: pluggableharness.trace.v1.SpanLink.attributes:type_name -> google.protobuf.Struct + 0, // 4: pluggableharness.trace.v1.Span.kind:type_name -> pluggableharness.trace.v1.SpanKind + 7, // 5: pluggableharness.trace.v1.Span.start_time:type_name -> google.protobuf.Timestamp + 7, // 6: pluggableharness.trace.v1.Span.end_time:type_name -> google.protobuf.Timestamp + 2, // 7: pluggableharness.trace.v1.Span.status:type_name -> pluggableharness.trace.v1.Status + 8, // 8: pluggableharness.trace.v1.Span.attributes:type_name -> google.protobuf.Struct + 4, // 9: pluggableharness.trace.v1.Span.events:type_name -> pluggableharness.trace.v1.SpanEvent + 5, // 10: pluggableharness.trace.v1.Span.links:type_name -> pluggableharness.trace.v1.SpanLink + 3, // 11: pluggableharness.trace.v1.Span.scope:type_name -> pluggableharness.trace.v1.InstrumentationScope + 12, // [12:12] is the sub-list for method output_type + 12, // [12:12] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name +} + +func init() { file_pluggableharness_trace_v1_types_proto_init() } +func file_pluggableharness_trace_v1_types_proto_init() { + if File_pluggableharness_trace_v1_types_proto != nil { + return + } + file_pluggableharness_trace_v1_types_proto_msgTypes[4].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_trace_v1_types_proto_rawDesc), len(file_pluggableharness_trace_v1_types_proto_rawDesc)), + NumEnums: 2, + NumMessages: 5, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_trace_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_trace_v1_types_proto_depIdxs, + EnumInfos: file_pluggableharness_trace_v1_types_proto_enumTypes, + MessageInfos: file_pluggableharness_trace_v1_types_proto_msgTypes, + }.Build() + File_pluggableharness_trace_v1_types_proto = out.File + file_pluggableharness_trace_v1_types_proto_goTypes = nil + file_pluggableharness_trace_v1_types_proto_depIdxs = nil +} diff --git a/pkg/widget/capabilities.go b/pkg/widget/capabilities.go new file mode 100644 index 0000000..ca729f6 --- /dev/null +++ b/pkg/widget/capabilities.go @@ -0,0 +1,22 @@ +package widget + +import ( + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// NewCapabilities builds a Capabilities from a config schema built with +// pkg/config (Schema and Attribute), the regions this widget intends to +// contribute to, and the hook points it can subscribe to in observe mode. +// It is a plain constructor, not a validating one — pkg/config.Schema +// already validates configSchema's own invariants before a caller has one +// to pass here, and Regions/SupportedHookPoints are enum lists with no +// further invariant of their own to check. +func NewCapabilities(configSchema *configv1.ConfigSchema, regions []renderv1.Region, hookPoints ...commonv1.HookPoint) Capabilities { + return Capabilities{ + Regions: regions, + ConfigSchema: configSchema, + SupportedHookPoints: hookPoints, + } +} diff --git a/pkg/widget/capabilities_test.go b/pkg/widget/capabilities_test.go new file mode 100644 index 0000000..fa2e06f --- /dev/null +++ b/pkg/widget/capabilities_test.go @@ -0,0 +1,51 @@ +package widget_test + +import ( + "testing" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + "github.com/pluggableharness/agent/pkg/config" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" + "github.com/pluggableharness/agent/pkg/widget" +) + +func TestNewCapabilities(t *testing.T) { + t.Parallel() + + attr, err := config.Attribute("enabled", configv1.AttrType_ATTR_TYPE_BOOL) + if err != nil { + t.Fatalf("config.Attribute: %v", err) + } + schema, err := config.Schema(attr) + if err != nil { + t.Fatalf("config.Schema: %v", err) + } + regions := []renderv1.Region{renderv1.Region_REGION_SIDEBAR, renderv1.Region_REGION_TOP_BAR} + + got := widget.NewCapabilities(schema, regions, commonv1.HookPoint_HOOK_POINT_POST_TOOL_CALL, commonv1.HookPoint_HOOK_POINT_SESSION_START) + + if got.ConfigSchema != schema { + t.Errorf("NewCapabilities().ConfigSchema = %p, want %p", got.ConfigSchema, schema) + } + if len(got.Regions) != 2 || got.Regions[0] != renderv1.Region_REGION_SIDEBAR || got.Regions[1] != renderv1.Region_REGION_TOP_BAR { + t.Errorf("NewCapabilities().Regions = %v, want %v", got.Regions, regions) + } + wantHooks := []commonv1.HookPoint{commonv1.HookPoint_HOOK_POINT_POST_TOOL_CALL, commonv1.HookPoint_HOOK_POINT_SESSION_START} + if len(got.SupportedHookPoints) != 2 || got.SupportedHookPoints[0] != wantHooks[0] || got.SupportedHookPoints[1] != wantHooks[1] { + t.Errorf("NewCapabilities().SupportedHookPoints = %v, want %v", got.SupportedHookPoints, wantHooks) + } +} + +func TestNewCapabilities_noHookPoints(t *testing.T) { + t.Parallel() + + got := widget.NewCapabilities(nil, []renderv1.Region{renderv1.Region_REGION_OVERLAY}) + + if got.SupportedHookPoints != nil { + t.Errorf("NewCapabilities().SupportedHookPoints = %v, want nil", got.SupportedHookPoints) + } + if got.ConfigSchema != nil { + t.Errorf("NewCapabilities().ConfigSchema = %v, want nil", got.ConfigSchema) + } +} diff --git a/pkg/widget/convert.go b/pkg/widget/convert.go new file mode 100644 index 0000000..cbf9fd0 --- /dev/null +++ b/pkg/widget/convert.go @@ -0,0 +1,32 @@ +package widget + +import ( + widgetv1 "github.com/pluggableharness/agent/pkg/widget/proto/v1" +) + +// toProtoCapabilities converts a domain Capabilities to its wire +// representation, for Service.GetCapabilities' response. +func toProtoCapabilities(caps Capabilities) *widgetv1.WidgetCapabilities { + return &widgetv1.WidgetCapabilities{ + Regions: caps.Regions, + ConfigSchema: caps.ConfigSchema, + SupportedHookPoints: caps.SupportedHookPoints, + } +} + +// fromProtoAttachRequest converts a wire AttachRequest to its domain +// representation, for handing to Provider.Attach. +func fromProtoAttachRequest(req *widgetv1.AttachRequest) AttachRequest { + return AttachRequest{SessionID: req.GetSessionId()} +} + +// toProtoUpdate converts a domain Update to its wire representation, +// translating Mode to the wire's bare Replace bool (UpdateReplace -> true, +// UpdateAppend -> false) for UpdateSender.Send. +func toProtoUpdate(u Update) *widgetv1.WidgetUpdate { + return &widgetv1.WidgetUpdate{ + Region: u.Region, + Content: u.Content, + Replace: u.Mode == UpdateReplace, + } +} diff --git a/pkg/widget/doc.go b/pkg/widget/doc.go new file mode 100644 index 0000000..ed4b45c --- /dev/null +++ b/pkg/widget/doc.go @@ -0,0 +1,79 @@ +// Package widget is the plugin-author-facing SDK for the widget provider +// category described in docs/specifications/frontend/widget-protocol.md — +// a plugin that contributes content into whichever frontend is attached, +// without owning the terminal/window/voice channel itself (a git-status +// panel, a context-budget indicator — content that isn't naturally "a +// tool" or "a context provider," it just wants to put something on +// screen). Despite living under docs/specifications/frontend/ alongside +// the frontend provider protocol, the widget protocol is generated as its +// own pkg/widget/proto/v1 package, distinct from pkg/frontend. +// +// # The Attach name collision — read this before touching Attach +// +// This package's Attach RPC shares its name with pkg/frontend's Attach +// but has a genuinely different shape, and conflating the two is the +// single easiest mistake to make when building on this SDK +// (docs/specifications/frontend/widget-protocol.md#transport): +// +// - frontend Attach is BIDIRECTIONAL and CONNECTION-scoped: one stream +// multiplexes every session a frontend has subscribed to, correlated +// by session_id on each message it carries. +// - widget Attach — this package's Attach — is SERVER-STREAMING ONLY +// and SESSION-scoped: one call per session, identified once by +// AttachRequest.SessionID, never multiplexed across sessions on a +// single connection. A widget instance serving three attached +// sessions gets three separate Attach calls, not one call carrying +// three sessions' worth of updates. +// +// Do not port a connection-multiplexing design onto this Attach — it is +// structurally simpler than the frontend protocol's Attach, not a variant +// of it. Service.Attach (server.go) and UpdateSender (stream.go) are +// built around exactly one call, one session, one Provider.Attach +// invocation. +// +// # Package shape +// +// Provider (widget.go) is the interface a widget plugin author +// implements: GetCapabilities, Configure, and Attach. NewService +// (server.go) adapts a Provider to widgetv1.WidgetServiceServer for +// registration via plugin.Config.Services (pkg/plugin); Describe is +// implemented directly from the plugin.Identity passed to NewService, +// per widget-protocol.md#transport's dev_overrides-identity discussion, +// rather than delegated to the Provider — every category protocol gains +// this identical RPC specifically so a dev_overrides-resolved binary, +// which has no provider {} lock-file entry, can still report its own +// identity. +// +// # Passive in v1 — no action-triggering mechanism here +// +// Widgets are display-only in this protocol revision +// (docs/specifications/frontend/widget-protocol.md#interactive-widgets). +// A WidgetUpdate's RenderTree MAY include an ActionNode the same way any +// other producer's render tree can, but a widget wanting to trigger +// something does so by also implementing SlashCommandService in the same +// plugin process (pkg/slashcommand, a sibling package — not built here). +// This package deliberately has no action-dispatch or action-receiving +// machinery of its own; don't invent one. +// +// # Deriving display state +// +// A widget gets no special session-state API. It derives whatever it +// wants to display by subscribing to hook points in observe mode (rides +// HookSubscriberService, pkg/hook — a sibling package) and pushes the +// result out through Attach's WidgetUpdate stream +// (docs/specifications/frontend/widget-protocol.md#deriving-display-state--no-new-data-feed). +// Attach's stream is purely one-directional: it never receives anything +// back — there is no frontend-protocol-style ClientEvent equivalent here, +// and this package has no receive-side code to match one. +// +// # Errors +// +// Widget Attach has no in-band error channel at all — unlike pkg/frontend's +// Attach, which can report a recoverable error in-band via +// ServerEvent.error, this Attach is server-streaming only with no return +// channel besides the stream itself. Error (errors.go) is therefore +// always carried in the structured detail of a gRPC status returned from +// Configure or Attach, never as an in-band WidgetUpdate field — see +// docs/specifications/frontend/widget-protocol.md#error-taxonomy and +// docs/specifications/frontend/conformance.md#error-taxonomy. +package widget diff --git a/pkg/widget/errors.go b/pkg/widget/errors.go new file mode 100644 index 0000000..1db20b8 --- /dev/null +++ b/pkg/widget/errors.go @@ -0,0 +1,151 @@ +package widget + +import ( + "fmt" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/pluggableharness/agent/pkg/plugin" + widgetv1 "github.com/pluggableharness/agent/pkg/widget/proto/v1" +) + +// errorDomain is the google.rpc.ErrorInfo domain every Error-backed gRPC +// status carries, per plugin.StatusError's own "domain should be the +// calling category's own error-taxonomy name" convention. +const errorDomain = "widget.pluggableharness.dev" + +// Reason strings match +// docs/specifications/frontend/widget-protocol.md#error-taxonomy's +// category names exactly, so a caller comparing against the spec's own +// vocabulary doesn't have to translate an enum name. +const ( + reasonRenderFailed = "render_failed" + reasonRegionUnsupported = "region_unsupported" + reasonUnknown = "unknown" +) + +// metadataCategoryKey names the google.rpc.ErrorInfo metadata entry +// carrying WidgetErrorCategory's exact wire enum name (e.g. +// "WIDGET_ERROR_CATEGORY_RENDER_FAILED"), alongside the coarser reason +// string, so FromStatus can recover the precise category instead of +// re-deriving it from reason. +const metadataCategoryKey = "category" + +// Error is the widget category's structured error type — the domain-side +// representation of the wire WidgetError message +// (docs/specifications/frontend/widget-protocol.md#error-taxonomy). +// Unlike the frontend category's FrontendError, Error has no in-band wire +// representation on this package's server-streaming-only Attach — a +// Service always carries it in the structured detail of a gRPC status +// returned from Configure or Attach (see toStatus), never as an Update +// field. Construct one with RenderFailed, RegionUnsupported, or Unknown, +// or return any other error from a Provider method and let Service map it +// to WIDGET_ERROR_CATEGORY_UNKNOWN automatically. +type Error struct { + // Category classifies this error. + Category widgetv1.WidgetErrorCategory + // Message is a human-readable description. + Message string +} + +// Error implements error. +func (e *Error) Error() string { + return fmt.Sprintf("widget: %s: %s", e.Category, e.Message) +} + +// RenderFailed builds an Error reporting that a RenderTree or Update +// could not be produced. Maps to codes.Internal — a render-time failure +// isn't the caller's fault, and there is no more specific code for "this +// widget's rendering logic broke." +func RenderFailed(message string) *Error { + return &Error{Category: widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_RENDER_FAILED, Message: message} +} + +// RegionUnsupported builds an Error reporting that this widget was asked +// to produce a render it structurally can't — including a +// partial-failure update that renders for one Region but not another +// (docs/specifications/frontend/widget-protocol.md#error-taxonomy). Maps +// to codes.InvalidArgument, per widget-protocol.md#error-taxonomy's +// explicit "codes.InvalidArgument for ... a render this widget can't +// produce." +func RegionUnsupported(message string) *Error { + return &Error{Category: widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED, Message: message} +} + +// Unknown builds an Error for a failure that fits none of the other +// categories. Maps to codes.Internal, never codes.Unknown — the same +// "most specific code" discipline .claude/rules/grpc.md requires +// everywhere else in this protocol series. Service.toGRPCStatus also +// falls back to this constructor automatically for any Provider error +// that isn't already an *Error. +func Unknown(message string) *Error { + return &Error{Category: widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_UNKNOWN, Message: message} +} + +// grpcCode returns the codes.Code e maps to, per +// docs/specifications/frontend/widget-protocol.md#error-taxonomy. +func (e *Error) grpcCode() codes.Code { + switch e.Category { + case widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_RENDER_FAILED: + return codes.Internal + case widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED: + return codes.InvalidArgument + default: + // WIDGET_ERROR_CATEGORY_UNKNOWN, and WIDGET_ERROR_CATEGORY_UNSPECIFIED + // (a hand-built Error that skipped RenderFailed/ + // RegionUnsupported/Unknown), both fall through to codes.Internal. + return codes.Internal + } +} + +// reason returns e.Category's spec-vocabulary reason string. +func (e *Error) reason() string { + switch e.Category { + case widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_RENDER_FAILED: + return reasonRenderFailed + case widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED: + return reasonRegionUnsupported + default: + return reasonUnknown + } +} + +// toStatus builds the gRPC status a Service returns for e, per +// docs/specifications/frontend/widget-protocol.md#error-taxonomy: e is +// always carried in the structured detail of a gRPC status returned from +// Configure or Attach, never as an in-band Update field. +func (e *Error) toStatus() error { + return plugin.StatusError(e.grpcCode(), errorDomain, e.reason(), e.Message, map[string]string{ + metadataCategoryKey: e.Category.String(), + }) +} + +// FromStatus recovers an *Error from err if err is a gRPC status carrying +// this package's ErrorInfo detail (i.e. one built by toStatus, reached by +// a Provider's Configure or Attach method returning an error) — the +// Error-aware counterpart to errors.As, for a caller that received this +// error across the plugin boundary rather than constructed it locally. ok +// is false for any other error, including a plain codes.Canceled +// cancellation status, which +// docs/specifications/frontend/widget-protocol.md#error-taxonomy treats +// as never an application error in the first place. +func FromStatus(err error) (*Error, bool) { + st, ok := status.FromError(err) + if !ok { + return nil, false + } + for _, d := range st.Details() { + info, ok := d.(*errdetails.ErrorInfo) + if !ok || info.GetDomain() != errorDomain { + continue + } + category := widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_UNKNOWN + if n, ok := widgetv1.WidgetErrorCategory_value[info.GetMetadata()[metadataCategoryKey]]; ok { + category = widgetv1.WidgetErrorCategory(n) + } + return &Error{Category: category, Message: st.Message()}, true + } + return nil, false +} diff --git a/pkg/widget/errors_test.go b/pkg/widget/errors_test.go new file mode 100644 index 0000000..472cc59 --- /dev/null +++ b/pkg/widget/errors_test.go @@ -0,0 +1,53 @@ +package widget_test + +import ( + "errors" + "testing" + + "github.com/pluggableharness/agent/pkg/widget" +) + +func TestError_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err *widget.Error + want string + }{ + {name: "render failed", err: widget.RenderFailed("bad node"), want: "widget: WIDGET_ERROR_CATEGORY_RENDER_FAILED: bad node"}, + {name: "region unsupported", err: widget.RegionUnsupported("no sidebar"), want: "widget: WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED: no sidebar"}, + {name: "unknown", err: widget.Unknown("boom"), want: "widget: WIDGET_ERROR_CATEGORY_UNKNOWN: boom"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := tt.err.Error(); got != tt.want { + t.Errorf("Error() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFromStatus_notAStatusError(t *testing.T) { + t.Parallel() + + _, ok := widget.FromStatus(errors.New("plain error")) + if ok { + t.Error("FromStatus(plain error) ok = true, want false") + } +} + +func TestFromStatus_nilError(t *testing.T) { + t.Parallel() + + // status.FromError(nil) reports ok=true with a nil/OK status carrying + // no ErrorInfo detail, so FromStatus must still report ok=false here — + // there is no WidgetError to recover from success. + _, ok := widget.FromStatus(nil) + if ok { + t.Error("FromStatus(nil) ok = true, want false") + } +} diff --git a/pkg/widget/fake_test.go b/pkg/widget/fake_test.go new file mode 100644 index 0000000..fb52a11 --- /dev/null +++ b/pkg/widget/fake_test.go @@ -0,0 +1,42 @@ +package widget_test + +import ( + "context" + + structpb "google.golang.org/protobuf/types/known/structpb" + + "github.com/pluggableharness/agent/pkg/widget" +) + +// fakeProvider is a hand-written widget.Provider fake (go-testing.md: +// fakes, not mocking frameworks). Each method's behavior is controlled by +// a caller-set func field; a nil field returns a zero value and a nil +// error, which is enough for tests that only exercise one method. +type fakeProvider struct { + getCapabilitiesFunc func(ctx context.Context) (widget.Capabilities, error) + configureFunc func(ctx context.Context, config *structpb.Struct) error + attachFunc func(ctx context.Context, req widget.AttachRequest, sender *widget.UpdateSender) error +} + +func (f *fakeProvider) GetCapabilities(ctx context.Context) (widget.Capabilities, error) { + if f.getCapabilitiesFunc != nil { + return f.getCapabilitiesFunc(ctx) + } + return widget.Capabilities{}, nil +} + +func (f *fakeProvider) Configure(ctx context.Context, config *structpb.Struct) error { + if f.configureFunc != nil { + return f.configureFunc(ctx, config) + } + return nil +} + +func (f *fakeProvider) Attach(ctx context.Context, req widget.AttachRequest, sender *widget.UpdateSender) error { + if f.attachFunc != nil { + return f.attachFunc(ctx, req, sender) + } + return nil +} + +var _ widget.Provider = (*fakeProvider)(nil) diff --git a/pkg/widget/helpers_test.go b/pkg/widget/helpers_test.go new file mode 100644 index 0000000..da60698 --- /dev/null +++ b/pkg/widget/helpers_test.go @@ -0,0 +1,39 @@ +package widget_test + +import ( + "context" + "net" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + widgetv1 "github.com/pluggableharness/agent/pkg/widget/proto/v1" +) + +// newTestClient starts srv on an in-memory bufconn listener and returns a +// widgetv1.WidgetServiceClient dialed against it — a real gRPC round +// trip, not a hand-rolled interface fake, so these tests exercise the +// actual wire marshaling widget.Service's conversions produce. Modeled on +// pkg/kernel/helpers_test.go's newTestClient. +func newTestClient(t *testing.T, srv widgetv1.WidgetServiceServer) widgetv1.WidgetServiceClient { + t.Helper() + + const bufSize = 1 << 20 + lis := bufconn.Listen(bufSize) + + gs := grpc.NewServer() + widgetv1.RegisterWidgetServiceServer(gs, srv) + go func() { _ = gs.Serve(lis) }() + t.Cleanup(gs.Stop) + + dialer := func(context.Context, string) (net.Conn, error) { return lis.Dial() } + conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(dialer), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("grpc.NewClient: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + return widgetv1.NewWidgetServiceClient(conn) +} diff --git a/pkg/widget/proto/v1/errors.pb.go b/pkg/widget/proto/v1/errors.pb.go new file mode 100644 index 0000000..95ea7ac --- /dev/null +++ b/pkg/widget/proto/v1/errors.pb.go @@ -0,0 +1,210 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/widget/v1/errors.proto + +package widgetv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// WidgetErrorCategory classifies a WidgetError, mirroring +// FrontendErrorCategory's shape (frontend/v1/errors.proto) for the widget category — +// resolves frontend/conformance.md's prior open question of whether +// widgets need a structured error type of their own. +type WidgetErrorCategory int32 + +const ( + // Zero value. Never valid for a real error; its presence on the wire + // means a caller forgot to set the field. + WidgetErrorCategory_WIDGET_ERROR_CATEGORY_UNSPECIFIED WidgetErrorCategory = 0 + // A RenderTree or WidgetUpdate could not be displayed. + WidgetErrorCategory_WIDGET_ERROR_CATEGORY_RENDER_FAILED WidgetErrorCategory = 1 + // A WidgetUpdate named a Region this widget's frontend cannot honor. + WidgetErrorCategory_WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED WidgetErrorCategory = 2 + // An error that does not fit any other category. + WidgetErrorCategory_WIDGET_ERROR_CATEGORY_UNKNOWN WidgetErrorCategory = 3 +) + +// Enum value maps for WidgetErrorCategory. +var ( + WidgetErrorCategory_name = map[int32]string{ + 0: "WIDGET_ERROR_CATEGORY_UNSPECIFIED", + 1: "WIDGET_ERROR_CATEGORY_RENDER_FAILED", + 2: "WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED", + 3: "WIDGET_ERROR_CATEGORY_UNKNOWN", + } + WidgetErrorCategory_value = map[string]int32{ + "WIDGET_ERROR_CATEGORY_UNSPECIFIED": 0, + "WIDGET_ERROR_CATEGORY_RENDER_FAILED": 1, + "WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED": 2, + "WIDGET_ERROR_CATEGORY_UNKNOWN": 3, + } +) + +func (x WidgetErrorCategory) Enum() *WidgetErrorCategory { + p := new(WidgetErrorCategory) + *p = x + return p +} + +func (x WidgetErrorCategory) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (WidgetErrorCategory) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_widget_v1_errors_proto_enumTypes[0].Descriptor() +} + +func (WidgetErrorCategory) Type() protoreflect.EnumType { + return &file_pluggableharness_widget_v1_errors_proto_enumTypes[0] +} + +func (x WidgetErrorCategory) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use WidgetErrorCategory.Descriptor instead. +func (WidgetErrorCategory) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_widget_v1_errors_proto_rawDescGZIP(), []int{0} +} + +// WidgetError is the structured error type for the widget category, +// mirroring FrontendError (frontend/v1/errors.proto). Unlike the frontend category +// (whose Attach errors surface in-band via ServerEvent.Error), widget +// Attach has no return channel other than the stream itself — WidgetError +// is carried in the structured detail of a gRPC status on Configure or +// Attach, per .claude/rules/grpc.md's error-taxonomy discipline, not as an +// in-band stream message. +type WidgetError struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The error's category. + Category WidgetErrorCategory `protobuf:"varint,1,opt,name=category,proto3,enum=pluggableharness.widget.v1.WidgetErrorCategory" json:"category,omitempty"` + // A human-readable message. + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WidgetError) Reset() { + *x = WidgetError{} + mi := &file_pluggableharness_widget_v1_errors_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WidgetError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WidgetError) ProtoMessage() {} + +func (x *WidgetError) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_widget_v1_errors_proto_msgTypes[0] + 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 WidgetError.ProtoReflect.Descriptor instead. +func (*WidgetError) Descriptor() ([]byte, []int) { + return file_pluggableharness_widget_v1_errors_proto_rawDescGZIP(), []int{0} +} + +func (x *WidgetError) GetCategory() WidgetErrorCategory { + if x != nil { + return x.Category + } + return WidgetErrorCategory_WIDGET_ERROR_CATEGORY_UNSPECIFIED +} + +func (x *WidgetError) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +var File_pluggableharness_widget_v1_errors_proto protoreflect.FileDescriptor + +const file_pluggableharness_widget_v1_errors_proto_rawDesc = "" + + "\n" + + "'pluggableharness/widget/v1/errors.proto\x12\x1apluggableharness.widget.v1\"t\n" + + "\vWidgetError\x12K\n" + + "\bcategory\x18\x01 \x01(\x0e2/.pluggableharness.widget.v1.WidgetErrorCategoryR\bcategory\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage*\xb6\x01\n" + + "\x13WidgetErrorCategory\x12%\n" + + "!WIDGET_ERROR_CATEGORY_UNSPECIFIED\x10\x00\x12'\n" + + "#WIDGET_ERROR_CATEGORY_RENDER_FAILED\x10\x01\x12,\n" + + "(WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED\x10\x02\x12!\n" + + "\x1dWIDGET_ERROR_CATEGORY_UNKNOWN\x10\x03B@Z>github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1b\x06proto3" + +var ( + file_pluggableharness_widget_v1_errors_proto_rawDescOnce sync.Once + file_pluggableharness_widget_v1_errors_proto_rawDescData []byte +) + +func file_pluggableharness_widget_v1_errors_proto_rawDescGZIP() []byte { + file_pluggableharness_widget_v1_errors_proto_rawDescOnce.Do(func() { + file_pluggableharness_widget_v1_errors_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_widget_v1_errors_proto_rawDesc), len(file_pluggableharness_widget_v1_errors_proto_rawDesc))) + }) + return file_pluggableharness_widget_v1_errors_proto_rawDescData +} + +var file_pluggableharness_widget_v1_errors_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_pluggableharness_widget_v1_errors_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_pluggableharness_widget_v1_errors_proto_goTypes = []any{ + (WidgetErrorCategory)(0), // 0: pluggableharness.widget.v1.WidgetErrorCategory + (*WidgetError)(nil), // 1: pluggableharness.widget.v1.WidgetError +} +var file_pluggableharness_widget_v1_errors_proto_depIdxs = []int32{ + 0, // 0: pluggableharness.widget.v1.WidgetError.category:type_name -> pluggableharness.widget.v1.WidgetErrorCategory + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_pluggableharness_widget_v1_errors_proto_init() } +func file_pluggableharness_widget_v1_errors_proto_init() { + if File_pluggableharness_widget_v1_errors_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_widget_v1_errors_proto_rawDesc), len(file_pluggableharness_widget_v1_errors_proto_rawDesc)), + NumEnums: 1, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_widget_v1_errors_proto_goTypes, + DependencyIndexes: file_pluggableharness_widget_v1_errors_proto_depIdxs, + EnumInfos: file_pluggableharness_widget_v1_errors_proto_enumTypes, + MessageInfos: file_pluggableharness_widget_v1_errors_proto_msgTypes, + }.Build() + File_pluggableharness_widget_v1_errors_proto = out.File + file_pluggableharness_widget_v1_errors_proto_goTypes = nil + file_pluggableharness_widget_v1_errors_proto_depIdxs = nil +} diff --git a/pkg/widget/proto/v1/events.pb.go b/pkg/widget/proto/v1/events.pb.go new file mode 100644 index 0000000..cd4552c --- /dev/null +++ b/pkg/widget/proto/v1/events.pb.go @@ -0,0 +1,150 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/widget/v1/events.proto + +package widgetv1 + +import ( + v1 "github.com/pluggableharness/agent/pkg/render/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// WidgetUpdate is one pushed update to this widget's rendered content, per +// frontend.md §4.1. +type WidgetUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Which region this update places content into. + Region v1.Region `protobuf:"varint,1,opt,name=region,proto3,enum=pluggableharness.render.v1.Region" json:"region,omitempty"` + // The content to place. + Content *v1.RenderTree `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` + // True: replace this widget's prior content in `region`. False: append. + Replace bool `protobuf:"varint,3,opt,name=replace,proto3" json:"replace,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WidgetUpdate) Reset() { + *x = WidgetUpdate{} + mi := &file_pluggableharness_widget_v1_events_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WidgetUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WidgetUpdate) ProtoMessage() {} + +func (x *WidgetUpdate) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_widget_v1_events_proto_msgTypes[0] + 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 WidgetUpdate.ProtoReflect.Descriptor instead. +func (*WidgetUpdate) Descriptor() ([]byte, []int) { + return file_pluggableharness_widget_v1_events_proto_rawDescGZIP(), []int{0} +} + +func (x *WidgetUpdate) GetRegion() v1.Region { + if x != nil { + return x.Region + } + return v1.Region(0) +} + +func (x *WidgetUpdate) GetContent() *v1.RenderTree { + if x != nil { + return x.Content + } + return nil +} + +func (x *WidgetUpdate) GetReplace() bool { + if x != nil { + return x.Replace + } + return false +} + +var File_pluggableharness_widget_v1_events_proto protoreflect.FileDescriptor + +const file_pluggableharness_widget_v1_events_proto_rawDesc = "" + + "\n" + + "'pluggableharness/widget/v1/events.proto\x12\x1apluggableharness.widget.v1\x1a&pluggableharness/render/v1/types.proto\"\xa6\x01\n" + + "\fWidgetUpdate\x12:\n" + + "\x06region\x18\x01 \x01(\x0e2\".pluggableharness.render.v1.RegionR\x06region\x12@\n" + + "\acontent\x18\x02 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\acontent\x12\x18\n" + + "\areplace\x18\x03 \x01(\bR\areplaceB@Z>github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1b\x06proto3" + +var ( + file_pluggableharness_widget_v1_events_proto_rawDescOnce sync.Once + file_pluggableharness_widget_v1_events_proto_rawDescData []byte +) + +func file_pluggableharness_widget_v1_events_proto_rawDescGZIP() []byte { + file_pluggableharness_widget_v1_events_proto_rawDescOnce.Do(func() { + file_pluggableharness_widget_v1_events_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_widget_v1_events_proto_rawDesc), len(file_pluggableharness_widget_v1_events_proto_rawDesc))) + }) + return file_pluggableharness_widget_v1_events_proto_rawDescData +} + +var file_pluggableharness_widget_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_pluggableharness_widget_v1_events_proto_goTypes = []any{ + (*WidgetUpdate)(nil), // 0: pluggableharness.widget.v1.WidgetUpdate + (v1.Region)(0), // 1: pluggableharness.render.v1.Region + (*v1.RenderTree)(nil), // 2: pluggableharness.render.v1.RenderTree +} +var file_pluggableharness_widget_v1_events_proto_depIdxs = []int32{ + 1, // 0: pluggableharness.widget.v1.WidgetUpdate.region:type_name -> pluggableharness.render.v1.Region + 2, // 1: pluggableharness.widget.v1.WidgetUpdate.content:type_name -> pluggableharness.render.v1.RenderTree + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_pluggableharness_widget_v1_events_proto_init() } +func file_pluggableharness_widget_v1_events_proto_init() { + if File_pluggableharness_widget_v1_events_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_widget_v1_events_proto_rawDesc), len(file_pluggableharness_widget_v1_events_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_widget_v1_events_proto_goTypes, + DependencyIndexes: file_pluggableharness_widget_v1_events_proto_depIdxs, + MessageInfos: file_pluggableharness_widget_v1_events_proto_msgTypes, + }.Build() + File_pluggableharness_widget_v1_events_proto = out.File + file_pluggableharness_widget_v1_events_proto_goTypes = nil + file_pluggableharness_widget_v1_events_proto_depIdxs = nil +} diff --git a/pkg/widget/proto/v1/rpc_request.pb.go b/pkg/widget/proto/v1/rpc_request.pb.go new file mode 100644 index 0000000..915efce --- /dev/null +++ b/pkg/widget/proto/v1/rpc_request.pb.go @@ -0,0 +1,256 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/widget/v1/rpc_request.proto + +package widgetv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// DescribeRequest carries no fields — Describe takes no parameters. +type DescribeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeRequest) Reset() { + *x = DescribeRequest{} + mi := &file_pluggableharness_widget_v1_rpc_request_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeRequest) ProtoMessage() {} + +func (x *DescribeRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_widget_v1_rpc_request_proto_msgTypes[0] + 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 DescribeRequest.ProtoReflect.Descriptor instead. +func (*DescribeRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_widget_v1_rpc_request_proto_rawDescGZIP(), []int{0} +} + +// GetCapabilitiesRequest carries no fields — GetCapabilities takes no +// parameters. +type GetCapabilitiesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCapabilitiesRequest) Reset() { + *x = GetCapabilitiesRequest{} + mi := &file_pluggableharness_widget_v1_rpc_request_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCapabilitiesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCapabilitiesRequest) ProtoMessage() {} + +func (x *GetCapabilitiesRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_widget_v1_rpc_request_proto_msgTypes[1] + 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 GetCapabilitiesRequest.ProtoReflect.Descriptor instead. +func (*GetCapabilitiesRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_widget_v1_rpc_request_proto_rawDescGZIP(), []int{1} +} + +// ConfigureRequest carries this provider's already-decoded agent.hcl block. +type ConfigureRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The provider's already-decoded config, per frontend.md §4.1. + Config *structpb.Struct `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureRequest) Reset() { + *x = ConfigureRequest{} + mi := &file_pluggableharness_widget_v1_rpc_request_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureRequest) ProtoMessage() {} + +func (x *ConfigureRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_widget_v1_rpc_request_proto_msgTypes[2] + 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 ConfigureRequest.ProtoReflect.Descriptor instead. +func (*ConfigureRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_widget_v1_rpc_request_proto_rawDescGZIP(), []int{2} +} + +func (x *ConfigureRequest) GetConfig() *structpb.Struct { + if x != nil { + return x.Config + } + return nil +} + +// AttachRequest identifies which session's widget instance to attach to. +type AttachRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session this widget instance is attaching to. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AttachRequest) Reset() { + *x = AttachRequest{} + mi := &file_pluggableharness_widget_v1_rpc_request_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AttachRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AttachRequest) ProtoMessage() {} + +func (x *AttachRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_widget_v1_rpc_request_proto_msgTypes[3] + 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 AttachRequest.ProtoReflect.Descriptor instead. +func (*AttachRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_widget_v1_rpc_request_proto_rawDescGZIP(), []int{3} +} + +func (x *AttachRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +var File_pluggableharness_widget_v1_rpc_request_proto protoreflect.FileDescriptor + +const file_pluggableharness_widget_v1_rpc_request_proto_rawDesc = "" + + "\n" + + ",pluggableharness/widget/v1/rpc_request.proto\x12\x1apluggableharness.widget.v1\x1a\x1cgoogle/protobuf/struct.proto\"\x11\n" + + "\x0fDescribeRequest\"\x18\n" + + "\x16GetCapabilitiesRequest\"C\n" + + "\x10ConfigureRequest\x12/\n" + + "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06config\".\n" + + "\rAttachRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionIdB@Z>github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1b\x06proto3" + +var ( + file_pluggableharness_widget_v1_rpc_request_proto_rawDescOnce sync.Once + file_pluggableharness_widget_v1_rpc_request_proto_rawDescData []byte +) + +func file_pluggableharness_widget_v1_rpc_request_proto_rawDescGZIP() []byte { + file_pluggableharness_widget_v1_rpc_request_proto_rawDescOnce.Do(func() { + file_pluggableharness_widget_v1_rpc_request_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_widget_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_widget_v1_rpc_request_proto_rawDesc))) + }) + return file_pluggableharness_widget_v1_rpc_request_proto_rawDescData +} + +var file_pluggableharness_widget_v1_rpc_request_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_pluggableharness_widget_v1_rpc_request_proto_goTypes = []any{ + (*DescribeRequest)(nil), // 0: pluggableharness.widget.v1.DescribeRequest + (*GetCapabilitiesRequest)(nil), // 1: pluggableharness.widget.v1.GetCapabilitiesRequest + (*ConfigureRequest)(nil), // 2: pluggableharness.widget.v1.ConfigureRequest + (*AttachRequest)(nil), // 3: pluggableharness.widget.v1.AttachRequest + (*structpb.Struct)(nil), // 4: google.protobuf.Struct +} +var file_pluggableharness_widget_v1_rpc_request_proto_depIdxs = []int32{ + 4, // 0: pluggableharness.widget.v1.ConfigureRequest.config:type_name -> google.protobuf.Struct + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_pluggableharness_widget_v1_rpc_request_proto_init() } +func file_pluggableharness_widget_v1_rpc_request_proto_init() { + if File_pluggableharness_widget_v1_rpc_request_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_widget_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_widget_v1_rpc_request_proto_rawDesc)), + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_widget_v1_rpc_request_proto_goTypes, + DependencyIndexes: file_pluggableharness_widget_v1_rpc_request_proto_depIdxs, + MessageInfos: file_pluggableharness_widget_v1_rpc_request_proto_msgTypes, + }.Build() + File_pluggableharness_widget_v1_rpc_request_proto = out.File + file_pluggableharness_widget_v1_rpc_request_proto_goTypes = nil + file_pluggableharness_widget_v1_rpc_request_proto_depIdxs = nil +} diff --git a/pkg/widget/proto/v1/rpc_response.pb.go b/pkg/widget/proto/v1/rpc_response.pb.go new file mode 100644 index 0000000..bdac04b --- /dev/null +++ b/pkg/widget/proto/v1/rpc_response.pb.go @@ -0,0 +1,221 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/widget/v1/rpc_response.proto + +package widgetv1 + +import ( + v1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// DescribeResponse reports this plugin build's own identity, obtained +// directly from the running process rather than a lock-file row — +// configuration/lock-file.md's "dev_overrides and identity without a lock +// entry". +type DescribeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Producer *v1.ProducerRef `protobuf:"bytes,1,opt,name=producer,proto3" json:"producer,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeResponse) Reset() { + *x = DescribeResponse{} + mi := &file_pluggableharness_widget_v1_rpc_response_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeResponse) ProtoMessage() {} + +func (x *DescribeResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_widget_v1_rpc_response_proto_msgTypes[0] + 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 DescribeResponse.ProtoReflect.Descriptor instead. +func (*DescribeResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_widget_v1_rpc_response_proto_rawDescGZIP(), []int{0} +} + +func (x *DescribeResponse) GetProducer() *v1.ProducerRef { + if x != nil { + return x.Producer + } + return nil +} + +// GetCapabilitiesResponse wraps WidgetCapabilities for the RPC signature, +// per this repo's per-RPC envelope convention (.claude/rules/proto.md). +type GetCapabilitiesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Capabilities *WidgetCapabilities `protobuf:"bytes,1,opt,name=capabilities,proto3" json:"capabilities,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCapabilitiesResponse) Reset() { + *x = GetCapabilitiesResponse{} + mi := &file_pluggableharness_widget_v1_rpc_response_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCapabilitiesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCapabilitiesResponse) ProtoMessage() {} + +func (x *GetCapabilitiesResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_widget_v1_rpc_response_proto_msgTypes[1] + 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 GetCapabilitiesResponse.ProtoReflect.Descriptor instead. +func (*GetCapabilitiesResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_widget_v1_rpc_response_proto_rawDescGZIP(), []int{1} +} + +func (x *GetCapabilitiesResponse) GetCapabilities() *WidgetCapabilities { + if x != nil { + return x.Capabilities + } + return nil +} + +// ConfigureResponse is empty on success. Errors surface as a gRPC status +// per grpc.md — not an in-band field here. +type ConfigureResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureResponse) Reset() { + *x = ConfigureResponse{} + mi := &file_pluggableharness_widget_v1_rpc_response_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureResponse) ProtoMessage() {} + +func (x *ConfigureResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_widget_v1_rpc_response_proto_msgTypes[2] + 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 ConfigureResponse.ProtoReflect.Descriptor instead. +func (*ConfigureResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_widget_v1_rpc_response_proto_rawDescGZIP(), []int{2} +} + +var File_pluggableharness_widget_v1_rpc_response_proto protoreflect.FileDescriptor + +const file_pluggableharness_widget_v1_rpc_response_proto_rawDesc = "" + + "\n" + + "-pluggableharness/widget/v1/rpc_response.proto\x12\x1apluggableharness.widget.v1\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/widget/v1/types.proto\"W\n" + + "\x10DescribeResponse\x12C\n" + + "\bproducer\x18\x01 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\bproducer\"m\n" + + "\x17GetCapabilitiesResponse\x12R\n" + + "\fcapabilities\x18\x01 \x01(\v2..pluggableharness.widget.v1.WidgetCapabilitiesR\fcapabilities\"\x13\n" + + "\x11ConfigureResponseB@Z>github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1b\x06proto3" + +var ( + file_pluggableharness_widget_v1_rpc_response_proto_rawDescOnce sync.Once + file_pluggableharness_widget_v1_rpc_response_proto_rawDescData []byte +) + +func file_pluggableharness_widget_v1_rpc_response_proto_rawDescGZIP() []byte { + file_pluggableharness_widget_v1_rpc_response_proto_rawDescOnce.Do(func() { + file_pluggableharness_widget_v1_rpc_response_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_widget_v1_rpc_response_proto_rawDesc), len(file_pluggableharness_widget_v1_rpc_response_proto_rawDesc))) + }) + return file_pluggableharness_widget_v1_rpc_response_proto_rawDescData +} + +var file_pluggableharness_widget_v1_rpc_response_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_pluggableharness_widget_v1_rpc_response_proto_goTypes = []any{ + (*DescribeResponse)(nil), // 0: pluggableharness.widget.v1.DescribeResponse + (*GetCapabilitiesResponse)(nil), // 1: pluggableharness.widget.v1.GetCapabilitiesResponse + (*ConfigureResponse)(nil), // 2: pluggableharness.widget.v1.ConfigureResponse + (*v1.ProducerRef)(nil), // 3: pluggableharness.common.v1.ProducerRef + (*WidgetCapabilities)(nil), // 4: pluggableharness.widget.v1.WidgetCapabilities +} +var file_pluggableharness_widget_v1_rpc_response_proto_depIdxs = []int32{ + 3, // 0: pluggableharness.widget.v1.DescribeResponse.producer:type_name -> pluggableharness.common.v1.ProducerRef + 4, // 1: pluggableharness.widget.v1.GetCapabilitiesResponse.capabilities:type_name -> pluggableharness.widget.v1.WidgetCapabilities + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_pluggableharness_widget_v1_rpc_response_proto_init() } +func file_pluggableharness_widget_v1_rpc_response_proto_init() { + if File_pluggableharness_widget_v1_rpc_response_proto != nil { + return + } + file_pluggableharness_widget_v1_types_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_widget_v1_rpc_response_proto_rawDesc), len(file_pluggableharness_widget_v1_rpc_response_proto_rawDesc)), + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_widget_v1_rpc_response_proto_goTypes, + DependencyIndexes: file_pluggableharness_widget_v1_rpc_response_proto_depIdxs, + MessageInfos: file_pluggableharness_widget_v1_rpc_response_proto_msgTypes, + }.Build() + File_pluggableharness_widget_v1_rpc_response_proto = out.File + file_pluggableharness_widget_v1_rpc_response_proto_goTypes = nil + file_pluggableharness_widget_v1_rpc_response_proto_depIdxs = nil +} diff --git a/pkg/widget/proto/v1/service.pb.go b/pkg/widget/proto/v1/service.pb.go new file mode 100644 index 0000000..b90c602 --- /dev/null +++ b/pkg/widget/proto/v1/service.pb.go @@ -0,0 +1,90 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/widget/v1/service.proto + +// Package pluggableharness.widget.v1 defines the widget provider plugin protocol +// described in specifications/frontend.md §4 (Attach, action dispatch, ...). +// Messages and RPCs are added incrementally as the protocol is finalized; +// this file currently scaffolds the buf toolchain wiring — see +// .claude/rules/proto.md. + +package widgetv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_pluggableharness_widget_v1_service_proto protoreflect.FileDescriptor + +const file_pluggableharness_widget_v1_service_proto_rawDesc = "" + + "\n" + + "(pluggableharness/widget/v1/service.proto\x12\x1apluggableharness.widget.v1\x1a'pluggableharness/widget/v1/events.proto\x1a,pluggableharness/widget/v1/rpc_request.proto\x1a-pluggableharness/widget/v1/rpc_response.proto2\xbd\x03\n" + + "\rWidgetService\x12z\n" + + "\x0fGetCapabilities\x122.pluggableharness.widget.v1.GetCapabilitiesRequest\x1a3.pluggableharness.widget.v1.GetCapabilitiesResponse\x12h\n" + + "\tConfigure\x12,.pluggableharness.widget.v1.ConfigureRequest\x1a-.pluggableharness.widget.v1.ConfigureResponse\x12_\n" + + "\x06Attach\x12).pluggableharness.widget.v1.AttachRequest\x1a(.pluggableharness.widget.v1.WidgetUpdate0\x01\x12e\n" + + "\bDescribe\x12+.pluggableharness.widget.v1.DescribeRequest\x1a,.pluggableharness.widget.v1.DescribeResponseB@Z>github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1b\x06proto3" + +var file_pluggableharness_widget_v1_service_proto_goTypes = []any{ + (*GetCapabilitiesRequest)(nil), // 0: pluggableharness.widget.v1.GetCapabilitiesRequest + (*ConfigureRequest)(nil), // 1: pluggableharness.widget.v1.ConfigureRequest + (*AttachRequest)(nil), // 2: pluggableharness.widget.v1.AttachRequest + (*DescribeRequest)(nil), // 3: pluggableharness.widget.v1.DescribeRequest + (*GetCapabilitiesResponse)(nil), // 4: pluggableharness.widget.v1.GetCapabilitiesResponse + (*ConfigureResponse)(nil), // 5: pluggableharness.widget.v1.ConfigureResponse + (*WidgetUpdate)(nil), // 6: pluggableharness.widget.v1.WidgetUpdate + (*DescribeResponse)(nil), // 7: pluggableharness.widget.v1.DescribeResponse +} +var file_pluggableharness_widget_v1_service_proto_depIdxs = []int32{ + 0, // 0: pluggableharness.widget.v1.WidgetService.GetCapabilities:input_type -> pluggableharness.widget.v1.GetCapabilitiesRequest + 1, // 1: pluggableharness.widget.v1.WidgetService.Configure:input_type -> pluggableharness.widget.v1.ConfigureRequest + 2, // 2: pluggableharness.widget.v1.WidgetService.Attach:input_type -> pluggableharness.widget.v1.AttachRequest + 3, // 3: pluggableharness.widget.v1.WidgetService.Describe:input_type -> pluggableharness.widget.v1.DescribeRequest + 4, // 4: pluggableharness.widget.v1.WidgetService.GetCapabilities:output_type -> pluggableharness.widget.v1.GetCapabilitiesResponse + 5, // 5: pluggableharness.widget.v1.WidgetService.Configure:output_type -> pluggableharness.widget.v1.ConfigureResponse + 6, // 6: pluggableharness.widget.v1.WidgetService.Attach:output_type -> pluggableharness.widget.v1.WidgetUpdate + 7, // 7: pluggableharness.widget.v1.WidgetService.Describe:output_type -> pluggableharness.widget.v1.DescribeResponse + 4, // [4:8] is the sub-list for method output_type + 0, // [0:4] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_pluggableharness_widget_v1_service_proto_init() } +func file_pluggableharness_widget_v1_service_proto_init() { + if File_pluggableharness_widget_v1_service_proto != nil { + return + } + file_pluggableharness_widget_v1_events_proto_init() + file_pluggableharness_widget_v1_rpc_request_proto_init() + file_pluggableharness_widget_v1_rpc_response_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_widget_v1_service_proto_rawDesc), len(file_pluggableharness_widget_v1_service_proto_rawDesc)), + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_pluggableharness_widget_v1_service_proto_goTypes, + DependencyIndexes: file_pluggableharness_widget_v1_service_proto_depIdxs, + }.Build() + File_pluggableharness_widget_v1_service_proto = out.File + file_pluggableharness_widget_v1_service_proto_goTypes = nil + file_pluggableharness_widget_v1_service_proto_depIdxs = nil +} diff --git a/pkg/widget/proto/v1/widget_grpc.pb.go b/pkg/widget/proto/v1/service_grpc.pb.go similarity index 98% rename from pkg/widget/proto/v1/widget_grpc.pb.go rename to pkg/widget/proto/v1/service_grpc.pb.go index f98e92c..d44292c 100644 --- a/pkg/widget/proto/v1/widget_grpc.pb.go +++ b/pkg/widget/proto/v1/service_grpc.pb.go @@ -2,7 +2,7 @@ // versions: // - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) -// source: pluggableharness/widget/v1/widget.proto +// source: pluggableharness/widget/v1/service.proto // Package pluggableharness.widget.v1 defines the widget provider plugin protocol // described in specifications/frontend.md §4 (Attach, action dispatch, ...). @@ -70,7 +70,7 @@ type WidgetServiceClient interface { // Describe reports this plugin build's own identity — {name, version, // source, category, protocol_version} — directly from the running // process, rather than the kernel inferring it from a lock-file row. - // Every one of the six category protocols gains this identical RPC in + // Every one of the seven category protocols gains this identical RPC in // this protocol revision; it exists specifically for a // `dev_overrides`-resolved binary (configuration/lock-file.md's // "dev_overrides and identity without a lock entry"), which has no @@ -174,7 +174,7 @@ type WidgetServiceServer interface { // Describe reports this plugin build's own identity — {name, version, // source, category, protocol_version} — directly from the running // process, rather than the kernel inferring it from a lock-file row. - // Every one of the six category protocols gains this identical RPC in + // Every one of the seven category protocols gains this identical RPC in // this protocol revision; it exists specifically for a // `dev_overrides`-resolved binary (configuration/lock-file.md's // "dev_overrides and identity without a lock entry"), which has no @@ -315,5 +315,5 @@ var WidgetService_ServiceDesc = grpc.ServiceDesc{ ServerStreams: true, }, }, - Metadata: "pluggableharness/widget/v1/widget.proto", + Metadata: "pluggableharness/widget/v1/service.proto", } diff --git a/pkg/widget/proto/v1/types.pb.go b/pkg/widget/proto/v1/types.pb.go new file mode 100644 index 0000000..46aace3 --- /dev/null +++ b/pkg/widget/proto/v1/types.pb.go @@ -0,0 +1,158 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/widget/v1/types.proto + +package widgetv1 + +import ( + v12 "github.com/pluggableharness/agent/pkg/common/proto/v1" + v11 "github.com/pluggableharness/agent/pkg/config/proto/v1" + v1 "github.com/pluggableharness/agent/pkg/render/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// WidgetCapabilities is this widget provider's complete capability +// advertisement, per frontend.md §4.1. +type WidgetCapabilities struct { + state protoimpl.MessageState `protogen:"open.v1"` + // MUST — the regions this widget intends to contribute to. + Regions []v1.Region `protobuf:"varint,1,rep,packed,name=regions,proto3,enum=pluggableharness.render.v1.Region" json:"regions,omitempty"` + // This provider's agent.hcl config schema, per configuration.md §4 — + // what fields Configure's request may be decoded from. + ConfigSchema *v11.ConfigSchema `protobuf:"bytes,2,opt,name=config_schema,json=configSchema,proto3" json:"config_schema,omitempty"` + // Hook points this widget can subscribe to in observe mode + // (agent-loop/hook-dispatch.md), so a mis-declared agent.hcl hook{} + // block naming an unsupported point can be rejected at config-load + // time rather than failing at first dispatch. + SupportedHookPoints []v12.HookPoint `protobuf:"varint,3,rep,packed,name=supported_hook_points,json=supportedHookPoints,proto3,enum=pluggableharness.common.v1.HookPoint" json:"supported_hook_points,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WidgetCapabilities) Reset() { + *x = WidgetCapabilities{} + mi := &file_pluggableharness_widget_v1_types_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WidgetCapabilities) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WidgetCapabilities) ProtoMessage() {} + +func (x *WidgetCapabilities) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_widget_v1_types_proto_msgTypes[0] + 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 WidgetCapabilities.ProtoReflect.Descriptor instead. +func (*WidgetCapabilities) Descriptor() ([]byte, []int) { + return file_pluggableharness_widget_v1_types_proto_rawDescGZIP(), []int{0} +} + +func (x *WidgetCapabilities) GetRegions() []v1.Region { + if x != nil { + return x.Regions + } + return nil +} + +func (x *WidgetCapabilities) GetConfigSchema() *v11.ConfigSchema { + if x != nil { + return x.ConfigSchema + } + return nil +} + +func (x *WidgetCapabilities) GetSupportedHookPoints() []v12.HookPoint { + if x != nil { + return x.SupportedHookPoints + } + return nil +} + +var File_pluggableharness_widget_v1_types_proto protoreflect.FileDescriptor + +const file_pluggableharness_widget_v1_types_proto_rawDesc = "" + + "\n" + + "&pluggableharness/widget/v1/types.proto\x12\x1apluggableharness.widget.v1\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/config/v1/types.proto\x1a&pluggableharness/render/v1/types.proto\"\xfc\x01\n" + + "\x12WidgetCapabilities\x12<\n" + + "\aregions\x18\x01 \x03(\x0e2\".pluggableharness.render.v1.RegionR\aregions\x12M\n" + + "\rconfig_schema\x18\x02 \x01(\v2(.pluggableharness.config.v1.ConfigSchemaR\fconfigSchema\x12Y\n" + + "\x15supported_hook_points\x18\x03 \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPointsB@Z>github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1b\x06proto3" + +var ( + file_pluggableharness_widget_v1_types_proto_rawDescOnce sync.Once + file_pluggableharness_widget_v1_types_proto_rawDescData []byte +) + +func file_pluggableharness_widget_v1_types_proto_rawDescGZIP() []byte { + file_pluggableharness_widget_v1_types_proto_rawDescOnce.Do(func() { + file_pluggableharness_widget_v1_types_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_widget_v1_types_proto_rawDesc), len(file_pluggableharness_widget_v1_types_proto_rawDesc))) + }) + return file_pluggableharness_widget_v1_types_proto_rawDescData +} + +var file_pluggableharness_widget_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_pluggableharness_widget_v1_types_proto_goTypes = []any{ + (*WidgetCapabilities)(nil), // 0: pluggableharness.widget.v1.WidgetCapabilities + (v1.Region)(0), // 1: pluggableharness.render.v1.Region + (*v11.ConfigSchema)(nil), // 2: pluggableharness.config.v1.ConfigSchema + (v12.HookPoint)(0), // 3: pluggableharness.common.v1.HookPoint +} +var file_pluggableharness_widget_v1_types_proto_depIdxs = []int32{ + 1, // 0: pluggableharness.widget.v1.WidgetCapabilities.regions:type_name -> pluggableharness.render.v1.Region + 2, // 1: pluggableharness.widget.v1.WidgetCapabilities.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema + 3, // 2: pluggableharness.widget.v1.WidgetCapabilities.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_pluggableharness_widget_v1_types_proto_init() } +func file_pluggableharness_widget_v1_types_proto_init() { + if File_pluggableharness_widget_v1_types_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_widget_v1_types_proto_rawDesc), len(file_pluggableharness_widget_v1_types_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_widget_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_widget_v1_types_proto_depIdxs, + MessageInfos: file_pluggableharness_widget_v1_types_proto_msgTypes, + }.Build() + File_pluggableharness_widget_v1_types_proto = out.File + file_pluggableharness_widget_v1_types_proto_goTypes = nil + file_pluggableharness_widget_v1_types_proto_depIdxs = nil +} diff --git a/pkg/widget/proto/v1/widget.pb.go b/pkg/widget/proto/v1/widget.pb.go deleted file mode 100644 index 09b4d5e..0000000 --- a/pkg/widget/proto/v1/widget.pb.go +++ /dev/null @@ -1,709 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: pluggableharness/widget/v1/widget.proto - -// Package pluggableharness.widget.v1 defines the widget provider plugin protocol -// described in specifications/frontend.md §4 (Attach, action dispatch, ...). -// Messages and RPCs are added incrementally as the protocol is finalized; -// this file currently scaffolds the buf toolchain wiring — see -// .claude/rules/proto.md. - -package widgetv1 - -import ( - v1 "github.com/pluggableharness/agent/pkg/common/proto/v1" - v12 "github.com/pluggableharness/agent/pkg/config/proto/v1" - v11 "github.com/pluggableharness/agent/pkg/render/proto/v1" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - structpb "google.golang.org/protobuf/types/known/structpb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// WidgetErrorCategory classifies a WidgetError, mirroring -// FrontendErrorCategory's shape (frontend.proto) for the widget category — -// resolves frontend/conformance.md's prior open question of whether -// widgets need a structured error type of their own. -type WidgetErrorCategory int32 - -const ( - // Zero value. Never valid for a real error; its presence on the wire - // means a caller forgot to set the field. - WidgetErrorCategory_WIDGET_ERROR_CATEGORY_UNSPECIFIED WidgetErrorCategory = 0 - // A RenderTree or WidgetUpdate could not be displayed. - WidgetErrorCategory_WIDGET_ERROR_CATEGORY_RENDER_FAILED WidgetErrorCategory = 1 - // A WidgetUpdate named a Region this widget's frontend cannot honor. - WidgetErrorCategory_WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED WidgetErrorCategory = 2 - // An error that does not fit any other category. - WidgetErrorCategory_WIDGET_ERROR_CATEGORY_UNKNOWN WidgetErrorCategory = 3 -) - -// Enum value maps for WidgetErrorCategory. -var ( - WidgetErrorCategory_name = map[int32]string{ - 0: "WIDGET_ERROR_CATEGORY_UNSPECIFIED", - 1: "WIDGET_ERROR_CATEGORY_RENDER_FAILED", - 2: "WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED", - 3: "WIDGET_ERROR_CATEGORY_UNKNOWN", - } - WidgetErrorCategory_value = map[string]int32{ - "WIDGET_ERROR_CATEGORY_UNSPECIFIED": 0, - "WIDGET_ERROR_CATEGORY_RENDER_FAILED": 1, - "WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED": 2, - "WIDGET_ERROR_CATEGORY_UNKNOWN": 3, - } -) - -func (x WidgetErrorCategory) Enum() *WidgetErrorCategory { - p := new(WidgetErrorCategory) - *p = x - return p -} - -func (x WidgetErrorCategory) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (WidgetErrorCategory) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_widget_v1_widget_proto_enumTypes[0].Descriptor() -} - -func (WidgetErrorCategory) Type() protoreflect.EnumType { - return &file_pluggableharness_widget_v1_widget_proto_enumTypes[0] -} - -func (x WidgetErrorCategory) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use WidgetErrorCategory.Descriptor instead. -func (WidgetErrorCategory) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_widget_v1_widget_proto_rawDescGZIP(), []int{0} -} - -// DescribeRequest carries no fields — Describe takes no parameters. -type DescribeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DescribeRequest) Reset() { - *x = DescribeRequest{} - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DescribeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DescribeRequest) ProtoMessage() {} - -func (x *DescribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[0] - 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 DescribeRequest.ProtoReflect.Descriptor instead. -func (*DescribeRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_widget_v1_widget_proto_rawDescGZIP(), []int{0} -} - -// DescribeResponse reports this plugin build's own identity, obtained -// directly from the running process rather than a lock-file row — -// configuration/lock-file.md's "dev_overrides and identity without a lock -// entry". -type DescribeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Producer *v1.ProducerRef `protobuf:"bytes,1,opt,name=producer,proto3" json:"producer,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DescribeResponse) Reset() { - *x = DescribeResponse{} - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DescribeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DescribeResponse) ProtoMessage() {} - -func (x *DescribeResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[1] - 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 DescribeResponse.ProtoReflect.Descriptor instead. -func (*DescribeResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_widget_v1_widget_proto_rawDescGZIP(), []int{1} -} - -func (x *DescribeResponse) GetProducer() *v1.ProducerRef { - if x != nil { - return x.Producer - } - return nil -} - -// GetCapabilitiesRequest carries no fields — GetCapabilities takes no -// parameters. -type GetCapabilitiesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetCapabilitiesRequest) Reset() { - *x = GetCapabilitiesRequest{} - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetCapabilitiesRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetCapabilitiesRequest) ProtoMessage() {} - -func (x *GetCapabilitiesRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[2] - 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 GetCapabilitiesRequest.ProtoReflect.Descriptor instead. -func (*GetCapabilitiesRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_widget_v1_widget_proto_rawDescGZIP(), []int{2} -} - -// GetCapabilitiesResponse wraps WidgetCapabilities for the RPC signature, -// per this repo's per-RPC envelope convention (.claude/rules/proto.md). -type GetCapabilitiesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Capabilities *WidgetCapabilities `protobuf:"bytes,1,opt,name=capabilities,proto3" json:"capabilities,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetCapabilitiesResponse) Reset() { - *x = GetCapabilitiesResponse{} - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetCapabilitiesResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetCapabilitiesResponse) ProtoMessage() {} - -func (x *GetCapabilitiesResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[3] - 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 GetCapabilitiesResponse.ProtoReflect.Descriptor instead. -func (*GetCapabilitiesResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_widget_v1_widget_proto_rawDescGZIP(), []int{3} -} - -func (x *GetCapabilitiesResponse) GetCapabilities() *WidgetCapabilities { - if x != nil { - return x.Capabilities - } - return nil -} - -// WidgetCapabilities is this widget provider's complete capability -// advertisement, per frontend.md §4.1. -type WidgetCapabilities struct { - state protoimpl.MessageState `protogen:"open.v1"` - // MUST — the regions this widget intends to contribute to. - Regions []v11.Region `protobuf:"varint,1,rep,packed,name=regions,proto3,enum=pluggableharness.render.v1.Region" json:"regions,omitempty"` - // This provider's agent.hcl config schema, per configuration.md §4 — - // what fields Configure's request may be decoded from. - ConfigSchema *v12.ConfigSchema `protobuf:"bytes,2,opt,name=config_schema,json=configSchema,proto3" json:"config_schema,omitempty"` - // Hook points this widget can subscribe to in observe mode - // (agent-loop/hook-dispatch.md), so a mis-declared agent.hcl hook{} - // block naming an unsupported point can be rejected at config-load - // time rather than failing at first dispatch. - SupportedHookPoints []v1.HookPoint `protobuf:"varint,3,rep,packed,name=supported_hook_points,json=supportedHookPoints,proto3,enum=pluggableharness.common.v1.HookPoint" json:"supported_hook_points,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *WidgetCapabilities) Reset() { - *x = WidgetCapabilities{} - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *WidgetCapabilities) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*WidgetCapabilities) ProtoMessage() {} - -func (x *WidgetCapabilities) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[4] - 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 WidgetCapabilities.ProtoReflect.Descriptor instead. -func (*WidgetCapabilities) Descriptor() ([]byte, []int) { - return file_pluggableharness_widget_v1_widget_proto_rawDescGZIP(), []int{4} -} - -func (x *WidgetCapabilities) GetRegions() []v11.Region { - if x != nil { - return x.Regions - } - return nil -} - -func (x *WidgetCapabilities) GetConfigSchema() *v12.ConfigSchema { - if x != nil { - return x.ConfigSchema - } - return nil -} - -func (x *WidgetCapabilities) GetSupportedHookPoints() []v1.HookPoint { - if x != nil { - return x.SupportedHookPoints - } - return nil -} - -// ConfigureRequest carries this provider's already-decoded agent.hcl block. -type ConfigureRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The provider's already-decoded config, per frontend.md §4.1. - Config *structpb.Struct `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ConfigureRequest) Reset() { - *x = ConfigureRequest{} - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ConfigureRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConfigureRequest) ProtoMessage() {} - -func (x *ConfigureRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_widget_v1_widget_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 ConfigureRequest.ProtoReflect.Descriptor instead. -func (*ConfigureRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_widget_v1_widget_proto_rawDescGZIP(), []int{5} -} - -func (x *ConfigureRequest) GetConfig() *structpb.Struct { - if x != nil { - return x.Config - } - return nil -} - -// ConfigureResponse is empty on success. Errors surface as a gRPC status -// per grpc.md — not an in-band field here. -type ConfigureResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ConfigureResponse) Reset() { - *x = ConfigureResponse{} - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ConfigureResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConfigureResponse) ProtoMessage() {} - -func (x *ConfigureResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_widget_v1_widget_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 ConfigureResponse.ProtoReflect.Descriptor instead. -func (*ConfigureResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_widget_v1_widget_proto_rawDescGZIP(), []int{6} -} - -// AttachRequest identifies which session's widget instance to attach to. -type AttachRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The session this widget instance is attaching to. - SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AttachRequest) Reset() { - *x = AttachRequest{} - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AttachRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AttachRequest) ProtoMessage() {} - -func (x *AttachRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[7] - 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 AttachRequest.ProtoReflect.Descriptor instead. -func (*AttachRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_widget_v1_widget_proto_rawDescGZIP(), []int{7} -} - -func (x *AttachRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -// WidgetUpdate is one pushed update to this widget's rendered content, per -// frontend.md §4.1. -type WidgetUpdate struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Which region this update places content into. - Region v11.Region `protobuf:"varint,1,opt,name=region,proto3,enum=pluggableharness.render.v1.Region" json:"region,omitempty"` - // The content to place. - Content *v11.RenderTree `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` - // True: replace this widget's prior content in `region`. False: append. - Replace bool `protobuf:"varint,3,opt,name=replace,proto3" json:"replace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *WidgetUpdate) Reset() { - *x = WidgetUpdate{} - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *WidgetUpdate) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*WidgetUpdate) ProtoMessage() {} - -func (x *WidgetUpdate) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[8] - 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 WidgetUpdate.ProtoReflect.Descriptor instead. -func (*WidgetUpdate) Descriptor() ([]byte, []int) { - return file_pluggableharness_widget_v1_widget_proto_rawDescGZIP(), []int{8} -} - -func (x *WidgetUpdate) GetRegion() v11.Region { - if x != nil { - return x.Region - } - return v11.Region(0) -} - -func (x *WidgetUpdate) GetContent() *v11.RenderTree { - if x != nil { - return x.Content - } - return nil -} - -func (x *WidgetUpdate) GetReplace() bool { - if x != nil { - return x.Replace - } - return false -} - -// WidgetError is the structured error type for the widget category, -// mirroring FrontendError (frontend.proto). Unlike the frontend category -// (whose Attach errors surface in-band via ServerEvent.Error), widget -// Attach has no return channel other than the stream itself — WidgetError -// is carried in the structured detail of a gRPC status on Configure or -// Attach, per .claude/rules/grpc.md's error-taxonomy discipline, not as an -// in-band stream message. -type WidgetError struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The error's category. - Category WidgetErrorCategory `protobuf:"varint,1,opt,name=category,proto3,enum=pluggableharness.widget.v1.WidgetErrorCategory" json:"category,omitempty"` - // A human-readable message. - Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *WidgetError) Reset() { - *x = WidgetError{} - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *WidgetError) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*WidgetError) ProtoMessage() {} - -func (x *WidgetError) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_widget_v1_widget_proto_msgTypes[9] - 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 WidgetError.ProtoReflect.Descriptor instead. -func (*WidgetError) Descriptor() ([]byte, []int) { - return file_pluggableharness_widget_v1_widget_proto_rawDescGZIP(), []int{9} -} - -func (x *WidgetError) GetCategory() WidgetErrorCategory { - if x != nil { - return x.Category - } - return WidgetErrorCategory_WIDGET_ERROR_CATEGORY_UNSPECIFIED -} - -func (x *WidgetError) GetMessage() string { - if x != nil { - return x.Message - } - return "" -} - -var File_pluggableharness_widget_v1_widget_proto protoreflect.FileDescriptor - -const file_pluggableharness_widget_v1_widget_proto_rawDesc = "" + - "\n" + - "'pluggableharness/widget/v1/widget.proto\x12\x1apluggableharness.widget.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a'pluggableharness/common/v1/common.proto\x1a'pluggableharness/config/v1/config.proto\x1a'pluggableharness/render/v1/render.proto\"\x11\n" + - "\x0fDescribeRequest\"W\n" + - "\x10DescribeResponse\x12C\n" + - "\bproducer\x18\x01 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\bproducer\"\x18\n" + - "\x16GetCapabilitiesRequest\"m\n" + - "\x17GetCapabilitiesResponse\x12R\n" + - "\fcapabilities\x18\x01 \x01(\v2..pluggableharness.widget.v1.WidgetCapabilitiesR\fcapabilities\"\xfc\x01\n" + - "\x12WidgetCapabilities\x12<\n" + - "\aregions\x18\x01 \x03(\x0e2\".pluggableharness.render.v1.RegionR\aregions\x12M\n" + - "\rconfig_schema\x18\x02 \x01(\v2(.pluggableharness.config.v1.ConfigSchemaR\fconfigSchema\x12Y\n" + - "\x15supported_hook_points\x18\x03 \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPoints\"C\n" + - "\x10ConfigureRequest\x12/\n" + - "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06config\"\x13\n" + - "\x11ConfigureResponse\".\n" + - "\rAttachRequest\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\"\xa6\x01\n" + - "\fWidgetUpdate\x12:\n" + - "\x06region\x18\x01 \x01(\x0e2\".pluggableharness.render.v1.RegionR\x06region\x12@\n" + - "\acontent\x18\x02 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\acontent\x12\x18\n" + - "\areplace\x18\x03 \x01(\bR\areplace\"t\n" + - "\vWidgetError\x12K\n" + - "\bcategory\x18\x01 \x01(\x0e2/.pluggableharness.widget.v1.WidgetErrorCategoryR\bcategory\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage*\xb6\x01\n" + - "\x13WidgetErrorCategory\x12%\n" + - "!WIDGET_ERROR_CATEGORY_UNSPECIFIED\x10\x00\x12'\n" + - "#WIDGET_ERROR_CATEGORY_RENDER_FAILED\x10\x01\x12,\n" + - "(WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED\x10\x02\x12!\n" + - "\x1dWIDGET_ERROR_CATEGORY_UNKNOWN\x10\x032\xbd\x03\n" + - "\rWidgetService\x12z\n" + - "\x0fGetCapabilities\x122.pluggableharness.widget.v1.GetCapabilitiesRequest\x1a3.pluggableharness.widget.v1.GetCapabilitiesResponse\x12h\n" + - "\tConfigure\x12,.pluggableharness.widget.v1.ConfigureRequest\x1a-.pluggableharness.widget.v1.ConfigureResponse\x12_\n" + - "\x06Attach\x12).pluggableharness.widget.v1.AttachRequest\x1a(.pluggableharness.widget.v1.WidgetUpdate0\x01\x12e\n" + - "\bDescribe\x12+.pluggableharness.widget.v1.DescribeRequest\x1a,.pluggableharness.widget.v1.DescribeResponseB@Z>github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1b\x06proto3" - -var ( - file_pluggableharness_widget_v1_widget_proto_rawDescOnce sync.Once - file_pluggableharness_widget_v1_widget_proto_rawDescData []byte -) - -func file_pluggableharness_widget_v1_widget_proto_rawDescGZIP() []byte { - file_pluggableharness_widget_v1_widget_proto_rawDescOnce.Do(func() { - file_pluggableharness_widget_v1_widget_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_widget_v1_widget_proto_rawDesc), len(file_pluggableharness_widget_v1_widget_proto_rawDesc))) - }) - return file_pluggableharness_widget_v1_widget_proto_rawDescData -} - -var file_pluggableharness_widget_v1_widget_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_pluggableharness_widget_v1_widget_proto_msgTypes = make([]protoimpl.MessageInfo, 10) -var file_pluggableharness_widget_v1_widget_proto_goTypes = []any{ - (WidgetErrorCategory)(0), // 0: pluggableharness.widget.v1.WidgetErrorCategory - (*DescribeRequest)(nil), // 1: pluggableharness.widget.v1.DescribeRequest - (*DescribeResponse)(nil), // 2: pluggableharness.widget.v1.DescribeResponse - (*GetCapabilitiesRequest)(nil), // 3: pluggableharness.widget.v1.GetCapabilitiesRequest - (*GetCapabilitiesResponse)(nil), // 4: pluggableharness.widget.v1.GetCapabilitiesResponse - (*WidgetCapabilities)(nil), // 5: pluggableharness.widget.v1.WidgetCapabilities - (*ConfigureRequest)(nil), // 6: pluggableharness.widget.v1.ConfigureRequest - (*ConfigureResponse)(nil), // 7: pluggableharness.widget.v1.ConfigureResponse - (*AttachRequest)(nil), // 8: pluggableharness.widget.v1.AttachRequest - (*WidgetUpdate)(nil), // 9: pluggableharness.widget.v1.WidgetUpdate - (*WidgetError)(nil), // 10: pluggableharness.widget.v1.WidgetError - (*v1.ProducerRef)(nil), // 11: pluggableharness.common.v1.ProducerRef - (v11.Region)(0), // 12: pluggableharness.render.v1.Region - (*v12.ConfigSchema)(nil), // 13: pluggableharness.config.v1.ConfigSchema - (v1.HookPoint)(0), // 14: pluggableharness.common.v1.HookPoint - (*structpb.Struct)(nil), // 15: google.protobuf.Struct - (*v11.RenderTree)(nil), // 16: pluggableharness.render.v1.RenderTree -} -var file_pluggableharness_widget_v1_widget_proto_depIdxs = []int32{ - 11, // 0: pluggableharness.widget.v1.DescribeResponse.producer:type_name -> pluggableharness.common.v1.ProducerRef - 5, // 1: pluggableharness.widget.v1.GetCapabilitiesResponse.capabilities:type_name -> pluggableharness.widget.v1.WidgetCapabilities - 12, // 2: pluggableharness.widget.v1.WidgetCapabilities.regions:type_name -> pluggableharness.render.v1.Region - 13, // 3: pluggableharness.widget.v1.WidgetCapabilities.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema - 14, // 4: pluggableharness.widget.v1.WidgetCapabilities.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint - 15, // 5: pluggableharness.widget.v1.ConfigureRequest.config:type_name -> google.protobuf.Struct - 12, // 6: pluggableharness.widget.v1.WidgetUpdate.region:type_name -> pluggableharness.render.v1.Region - 16, // 7: pluggableharness.widget.v1.WidgetUpdate.content:type_name -> pluggableharness.render.v1.RenderTree - 0, // 8: pluggableharness.widget.v1.WidgetError.category:type_name -> pluggableharness.widget.v1.WidgetErrorCategory - 3, // 9: pluggableharness.widget.v1.WidgetService.GetCapabilities:input_type -> pluggableharness.widget.v1.GetCapabilitiesRequest - 6, // 10: pluggableharness.widget.v1.WidgetService.Configure:input_type -> pluggableharness.widget.v1.ConfigureRequest - 8, // 11: pluggableharness.widget.v1.WidgetService.Attach:input_type -> pluggableharness.widget.v1.AttachRequest - 1, // 12: pluggableharness.widget.v1.WidgetService.Describe:input_type -> pluggableharness.widget.v1.DescribeRequest - 4, // 13: pluggableharness.widget.v1.WidgetService.GetCapabilities:output_type -> pluggableharness.widget.v1.GetCapabilitiesResponse - 7, // 14: pluggableharness.widget.v1.WidgetService.Configure:output_type -> pluggableharness.widget.v1.ConfigureResponse - 9, // 15: pluggableharness.widget.v1.WidgetService.Attach:output_type -> pluggableharness.widget.v1.WidgetUpdate - 2, // 16: pluggableharness.widget.v1.WidgetService.Describe:output_type -> pluggableharness.widget.v1.DescribeResponse - 13, // [13:17] is the sub-list for method output_type - 9, // [9:13] is the sub-list for method input_type - 9, // [9:9] is the sub-list for extension type_name - 9, // [9:9] is the sub-list for extension extendee - 0, // [0:9] is the sub-list for field type_name -} - -func init() { file_pluggableharness_widget_v1_widget_proto_init() } -func file_pluggableharness_widget_v1_widget_proto_init() { - if File_pluggableharness_widget_v1_widget_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_widget_v1_widget_proto_rawDesc), len(file_pluggableharness_widget_v1_widget_proto_rawDesc)), - NumEnums: 1, - NumMessages: 10, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_pluggableharness_widget_v1_widget_proto_goTypes, - DependencyIndexes: file_pluggableharness_widget_v1_widget_proto_depIdxs, - EnumInfos: file_pluggableharness_widget_v1_widget_proto_enumTypes, - MessageInfos: file_pluggableharness_widget_v1_widget_proto_msgTypes, - }.Build() - File_pluggableharness_widget_v1_widget_proto = out.File - file_pluggableharness_widget_v1_widget_proto_goTypes = nil - file_pluggableharness_widget_v1_widget_proto_depIdxs = nil -} diff --git a/pkg/widget/server.go b/pkg/widget/server.go new file mode 100644 index 0000000..4dc6fac --- /dev/null +++ b/pkg/widget/server.go @@ -0,0 +1,123 @@ +package widget + +import ( + "context" + "errors" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" + widgetv1 "github.com/pluggableharness/agent/pkg/widget/proto/v1" +) + +// Service adapts a Provider to widgetv1.WidgetServiceServer and satisfies +// plugin.Service, so it can be passed to plugin.Config.Services. Construct +// one with NewService; the zero value is not usable. +type Service struct { + widgetv1.UnimplementedWidgetServiceServer + + provider Provider + identity plugin.Identity + callback *plugin.Callback +} + +var ( + _ plugin.Service = (*Service)(nil) + _ widgetv1.WidgetServiceServer = (*Service)(nil) +) + +// NewService builds a Service adapting p to widgetv1.WidgetServiceServer. +// identity is this plugin build's own self-reported identity, used +// directly by Describe (via plugin.Identity.ProducerRef) rather than a +// lock-file row. callback is accepted for parity with this plugin +// process's other muxed services and exposed via Callback, so a Provider +// implementation can reach the kernel callback channel (structured +// logging, tracing, the event bus) from within its own methods — +// WidgetService's own RPCs never call back into the kernel themselves. +func NewService(p Provider, identity plugin.Identity, callback *plugin.Callback) *Service { + return &Service{provider: p, identity: identity, callback: callback} +} + +// Callback returns the kernel callback handle passed to NewService. +func (s *Service) Callback() *plugin.Callback { + return s.callback +} + +// Register registers this Service's WidgetServiceServer handler on gs, +// satisfying plugin.Service. +func (s *Service) Register(gs *grpc.Server) { + widgetv1.RegisterWidgetServiceServer(gs, s) +} + +// Describe reports this plugin build's own identity, obtained directly +// from the Identity passed to NewService rather than a lock-file row +// (docs/specifications/frontend/widget-protocol.md#transport). +func (s *Service) Describe(context.Context, *widgetv1.DescribeRequest) (*widgetv1.DescribeResponse, error) { + return &widgetv1.DescribeResponse{ + Producer: s.identity.ProducerRef(commonv1.Category_CATEGORY_WIDGET), + }, nil +} + +// GetCapabilities delegates to the Provider and converts its result to +// the wire representation. MUST be cheap and MUST NOT require a network +// call (docs/specifications/frontend/widget-protocol.md#transport) — that +// guarantee is the Provider implementation's responsibility, not this +// adapter's. +func (s *Service) GetCapabilities(ctx context.Context, _ *widgetv1.GetCapabilitiesRequest) (*widgetv1.GetCapabilitiesResponse, error) { + caps, err := s.provider.GetCapabilities(ctx) + if err != nil { + return nil, toGRPCStatus(err) + } + return &widgetv1.GetCapabilitiesResponse{Capabilities: toProtoCapabilities(caps)}, nil +} + +// Configure delegates to the Provider. A rejection surfaces as a gRPC +// status carrying an Error in its structured detail, per +// docs/specifications/frontend/widget-protocol.md#error-taxonomy — never +// an in-band field on ConfigureResponse, and never echoing a received +// secret back out. +func (s *Service) Configure(ctx context.Context, req *widgetv1.ConfigureRequest) (*widgetv1.ConfigureResponse, error) { + if err := s.provider.Configure(ctx, req.GetConfig()); err != nil { + return nil, toGRPCStatus(err) + } + return &widgetv1.ConfigureResponse{}, nil +} + +// Attach serves one session's update feed by delegating to the Provider, +// which pushes updates through an UpdateSender built from stream. Per +// docs/specifications/frontend/widget-protocol.md#transport, this Attach +// is server-streaming only and session-scoped — one call per session, +// never multiplexed across sessions on one connection — a genuinely +// different shape from the frontend protocol's bidirectional, +// connection-scoped Attach despite sharing the RPC name (see doc.go). +// Cancellation (the kernel closing the stream) surfaces as +// codes.Canceled, never as an application error. +func (s *Service) Attach(req *widgetv1.AttachRequest, stream widgetv1.WidgetService_AttachServer) error { + ctx := stream.Context() + sender := newUpdateSender(ctx, stream) + + if err := s.provider.Attach(ctx, fromProtoAttachRequest(req), sender); err != nil { + return toGRPCStatus(err) + } + return nil +} + +// toGRPCStatus maps err to a gRPC status. Cancellation always maps to +// codes.Canceled, never to an application error. An *Error maps per its +// own category (errors.go); any other error is treated as +// WIDGET_ERROR_CATEGORY_UNKNOWN, mapping to codes.Internal — never +// codes.Unknown — per +// docs/specifications/frontend/widget-protocol.md#error-taxonomy. +func toGRPCStatus(err error) error { + if errors.Is(err, context.Canceled) { + return status.Error(codes.Canceled, err.Error()) + } + var werr *Error + if !errors.As(err, &werr) { + werr = Unknown(err.Error()) + } + return werr.toStatus() +} diff --git a/pkg/widget/server_test.go b/pkg/widget/server_test.go new file mode 100644 index 0000000..ccfae2e --- /dev/null +++ b/pkg/widget/server_test.go @@ -0,0 +1,296 @@ +package widget_test + +import ( + "context" + "errors" + "io" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + structpb "google.golang.org/protobuf/types/known/structpb" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + "github.com/pluggableharness/agent/pkg/plugin" + "github.com/pluggableharness/agent/pkg/render" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" + "github.com/pluggableharness/agent/pkg/widget" + widgetv1 "github.com/pluggableharness/agent/pkg/widget/proto/v1" +) + +var testIdentity = plugin.Identity{ + Name: "git-status", + Version: "1.0.0", + Source: "github.com/agentco/git-status-widget", +} + +func TestService_Describe(t *testing.T) { + t.Parallel() + + svc := widget.NewService(&fakeProvider{}, testIdentity, plugin.NewCallback()) + client := newTestClient(t, svc) + + resp, err := client.Describe(t.Context(), &widgetv1.DescribeRequest{}) + if err != nil { + t.Fatalf("Describe: %v", err) + } + producer := resp.GetProducer() + if producer.GetName() != "git-status" || producer.GetVersion() != "1.0.0" || producer.GetSource() != "github.com/agentco/git-status-widget" { + t.Errorf("Describe() producer = %+v, want name/version/source from testIdentity", producer) + } + if producer.GetCategory() != commonv1.Category_CATEGORY_WIDGET { + t.Errorf("Describe() producer.Category = %v, want CATEGORY_WIDGET", producer.GetCategory()) + } +} + +func TestService_Callback(t *testing.T) { + t.Parallel() + + cb := plugin.NewCallback() + svc := widget.NewService(&fakeProvider{}, testIdentity, cb) + if got := svc.Callback(); got != cb { + t.Errorf("Callback() = %p, want %p", got, cb) + } +} + +func TestService_Register(t *testing.T) { + t.Parallel() + + svc := widget.NewService(&fakeProvider{}, testIdentity, plugin.NewCallback()) + gs := grpc.NewServer() + t.Cleanup(gs.Stop) + + svc.Register(gs) + + if _, ok := gs.GetServiceInfo()["pluggableharness.widget.v1.WidgetService"]; !ok { + t.Error("Register: WidgetService not registered on server") + } +} + +func TestService_GetCapabilities(t *testing.T) { + t.Parallel() + + want := widget.NewCapabilities(nil, []renderv1.Region{renderv1.Region_REGION_SIDEBAR}, commonv1.HookPoint_HOOK_POINT_POST_TOOL_CALL) + svc := widget.NewService(&fakeProvider{ + getCapabilitiesFunc: func(context.Context) (widget.Capabilities, error) { return want, nil }, + }, testIdentity, plugin.NewCallback()) + client := newTestClient(t, svc) + + resp, err := client.GetCapabilities(t.Context(), &widgetv1.GetCapabilitiesRequest{}) + if err != nil { + t.Fatalf("GetCapabilities: %v", err) + } + caps := resp.GetCapabilities() + if len(caps.GetRegions()) != 1 || caps.GetRegions()[0] != renderv1.Region_REGION_SIDEBAR { + t.Errorf("GetCapabilities().Regions = %v, want [REGION_SIDEBAR]", caps.GetRegions()) + } + if len(caps.GetSupportedHookPoints()) != 1 || caps.GetSupportedHookPoints()[0] != commonv1.HookPoint_HOOK_POINT_POST_TOOL_CALL { + t.Errorf("GetCapabilities().SupportedHookPoints = %v, want [HOOK_POINT_POST_TOOL_CALL]", caps.GetSupportedHookPoints()) + } +} + +func TestService_GetCapabilities_error(t *testing.T) { + t.Parallel() + + svc := widget.NewService(&fakeProvider{ + getCapabilitiesFunc: func(context.Context) (widget.Capabilities, error) { + return widget.Capabilities{}, widget.RenderFailed("cannot build schema") + }, + }, testIdentity, plugin.NewCallback()) + client := newTestClient(t, svc) + + _, err := client.GetCapabilities(t.Context(), &widgetv1.GetCapabilitiesRequest{}) + assertWidgetStatus(t, err, codes.Internal, widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_RENDER_FAILED, "cannot build schema") +} + +func TestService_Configure_success(t *testing.T) { + t.Parallel() + + var gotConfig bool + svc := widget.NewService(&fakeProvider{ + configureFunc: func(context.Context, *structpb.Struct) error { gotConfig = true; return nil }, + }, testIdentity, plugin.NewCallback()) + client := newTestClient(t, svc) + + if _, err := client.Configure(t.Context(), &widgetv1.ConfigureRequest{}); err != nil { + t.Fatalf("Configure: %v", err) + } + if !gotConfig { + t.Error("Configure: Provider.Configure was not called") + } +} + +func TestService_Configure_regionUnsupportedError(t *testing.T) { + t.Parallel() + + svc := widget.NewService(&fakeProvider{ + configureFunc: func(context.Context, *structpb.Struct) error { + return widget.RegionUnsupported("this widget has no sidebar support") + }, + }, testIdentity, plugin.NewCallback()) + client := newTestClient(t, svc) + + _, err := client.Configure(t.Context(), &widgetv1.ConfigureRequest{}) + assertWidgetStatus(t, err, codes.InvalidArgument, widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED, "this widget has no sidebar support") +} + +func TestService_Configure_plainErrorMapsToUnknown(t *testing.T) { + t.Parallel() + + svc := widget.NewService(&fakeProvider{ + configureFunc: func(context.Context, *structpb.Struct) error { return errors.New("malformed config") }, + }, testIdentity, plugin.NewCallback()) + client := newTestClient(t, svc) + + _, err := client.Configure(t.Context(), &widgetv1.ConfigureRequest{}) + assertWidgetStatus(t, err, codes.Internal, widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_UNKNOWN, "malformed config") +} + +func TestService_Attach_pushesUpdatesWithSessionID(t *testing.T) { + t.Parallel() + + var gotSessionID string + svc := widget.NewService(&fakeProvider{ + attachFunc: func(_ context.Context, req widget.AttachRequest, sender *widget.UpdateSender) error { + gotSessionID = req.SessionID + if err := sender.Send(widget.Update{ + Region: renderv1.Region_REGION_SIDEBAR, + Content: render.Tree(render.Text("first")), + Mode: widget.UpdateAppend, + }); err != nil { + return err + } + return sender.Send(widget.Update{ + Region: renderv1.Region_REGION_TOP_BAR, + Content: render.Tree(render.Text("second")), + Mode: widget.UpdateReplace, + }) + }, + }, testIdentity, plugin.NewCallback()) + client := newTestClient(t, svc) + + stream, err := client.Attach(t.Context(), &widgetv1.AttachRequest{SessionId: "session-01"}) + if err != nil { + t.Fatalf("Attach: %v", err) + } + + first, err := stream.Recv() + if err != nil { + t.Fatalf("Recv (first): %v", err) + } + if first.GetRegion() != renderv1.Region_REGION_SIDEBAR || first.GetReplace() { + t.Errorf("first update = region=%v replace=%v, want region=REGION_SIDEBAR replace=false (append)", first.GetRegion(), first.GetReplace()) + } + + second, err := stream.Recv() + if err != nil { + t.Fatalf("Recv (second): %v", err) + } + if second.GetRegion() != renderv1.Region_REGION_TOP_BAR || !second.GetReplace() { + t.Errorf("second update = region=%v replace=%v, want region=REGION_TOP_BAR replace=true", second.GetRegion(), second.GetReplace()) + } + + if _, err := stream.Recv(); !errors.Is(err, io.EOF) { + t.Errorf("Recv (third): err = %v, want io.EOF", err) + } + if gotSessionID != "session-01" { + t.Errorf("Provider.Attach saw SessionID = %q, want session-01", gotSessionID) + } +} + +func TestService_Attach_error(t *testing.T) { + t.Parallel() + + svc := widget.NewService(&fakeProvider{ + attachFunc: func(context.Context, widget.AttachRequest, *widget.UpdateSender) error { + return widget.RegionUnsupported("no overlay support") + }, + }, testIdentity, plugin.NewCallback()) + client := newTestClient(t, svc) + + stream, err := client.Attach(t.Context(), &widgetv1.AttachRequest{SessionId: "session-01"}) + if err != nil { + t.Fatalf("Attach: %v", err) + } + + _, err = stream.Recv() + assertWidgetStatus(t, err, codes.InvalidArgument, widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED, "no overlay support") +} + +// TestService_Attach_clientCancelIsCleanShutdown exercises +// widget-protocol.md#transport's cancellation discipline: the kernel +// (here, the test client) closing the Attach stream mid-flight MUST be +// treated as normal control flow by both UpdateSender.Send and the +// Provider, never as an application error to report. +func TestService_Attach_clientCancelIsCleanShutdown(t *testing.T) { + t.Parallel() + + providerDone := make(chan struct{}) + var sendAfterCancelErr error + + svc := widget.NewService(&fakeProvider{ + attachFunc: func(ctx context.Context, _ widget.AttachRequest, sender *widget.UpdateSender) error { + defer close(providerDone) + if err := sender.Send(widget.Update{ + Region: renderv1.Region_REGION_SIDEBAR, + Content: render.Tree(render.Text("hello")), + }); err != nil { + return err + } + <-ctx.Done() + sendAfterCancelErr = sender.Send(widget.Update{ + Region: renderv1.Region_REGION_SIDEBAR, + Content: render.Tree(render.Text("after cancel")), + }) + return ctx.Err() + }, + }, testIdentity, plugin.NewCallback()) + client := newTestClient(t, svc) + + ctx, cancel := context.WithCancel(t.Context()) + stream, err := client.Attach(ctx, &widgetv1.AttachRequest{SessionId: "session-01"}) + if err != nil { + t.Fatalf("Attach: %v", err) + } + if _, err := stream.Recv(); err != nil { + t.Fatalf("Recv: %v", err) + } + + cancel() + + select { + case <-providerDone: + case <-time.After(5 * time.Second): + t.Fatal("Provider.Attach did not observe cancellation within 5s") + } + + if !errors.Is(sendAfterCancelErr, context.Canceled) { + t.Errorf("Send after cancel = %v, want context.Canceled", sendAfterCancelErr) + } +} + +// assertWidgetStatus asserts err is a gRPC status with code wantCode +// carrying a *widget.Error with category wantCategory and message +// wantMessage, recovered via widget.FromStatus. +func assertWidgetStatus(t *testing.T, err error, wantCode codes.Code, wantCategory widgetv1.WidgetErrorCategory, wantMessage string) { + t.Helper() + + if err == nil { + t.Fatal("want error, got nil") + } + if got := status.Code(err); got != wantCode { + t.Errorf("status.Code(err) = %v, want %v", got, wantCode) + } + werr, ok := widget.FromStatus(err) + if !ok { + t.Fatalf("FromStatus(%v) ok = false, want true", err) + } + if werr.Category != wantCategory { + t.Errorf("FromStatus(err).Category = %v, want %v", werr.Category, wantCategory) + } + if werr.Message != wantMessage { + t.Errorf("FromStatus(err).Message = %q, want %q", werr.Message, wantMessage) + } +} diff --git a/pkg/widget/stream.go b/pkg/widget/stream.go new file mode 100644 index 0000000..3b21987 --- /dev/null +++ b/pkg/widget/stream.go @@ -0,0 +1,44 @@ +package widget + +import ( + "context" + "fmt" + + widgetv1 "github.com/pluggableharness/agent/pkg/widget/proto/v1" +) + +// UpdateSender is the cancellation-safe handle a Provider's Attach method +// uses to push Update values for one session's Attach call. The zero +// value is not usable; Service.Attach constructs one per call and hands +// it to the Provider — a Provider never constructs one itself. +type UpdateSender struct { + ctx context.Context + stream widgetv1.WidgetService_AttachServer +} + +// newUpdateSender builds an UpdateSender bound to one Attach call's +// stream and context. +func newUpdateSender(ctx context.Context, stream widgetv1.WidgetService_AttachServer) *UpdateSender { + return &UpdateSender{ctx: ctx, stream: stream} +} + +// Send converts update to its wire representation and writes it to the +// underlying stream. If the session's context has already been +// canceled — the kernel closing this Attach call, ordinary control flow +// per docs/specifications/frontend/widget-protocol.md#transport, never an +// application error — Send returns ctx.Err() directly without attempting +// the write, so a Provider's Attach loop can check errors.Is(err, +// context.Canceled) uniformly regardless of whether cancellation landed +// before or during the write. +func (s *UpdateSender) Send(update Update) error { + if err := s.ctx.Err(); err != nil { + return err + } + if err := s.stream.Send(toProtoUpdate(update)); err != nil { + if ctxErr := s.ctx.Err(); ctxErr != nil { + return ctxErr + } + return fmt.Errorf("widget: send update: %w", err) + } + return nil +} diff --git a/pkg/widget/stream_internal_test.go b/pkg/widget/stream_internal_test.go new file mode 100644 index 0000000..9084722 --- /dev/null +++ b/pkg/widget/stream_internal_test.go @@ -0,0 +1,78 @@ +package widget + +import ( + "context" + "errors" + "testing" + + "google.golang.org/grpc/metadata" + + widgetv1 "github.com/pluggableharness/agent/pkg/widget/proto/v1" +) + +// fakeAttachStream is a hand-written widgetv1.WidgetService_AttachServer +// fake (go-testing.md: fakes, not mocking frameworks), used to exercise +// UpdateSender.Send's branches that a real bufconn round trip can't +// deterministically reach — in particular a stream-level send failure +// that is not itself caused by context cancellation. server_test.go +// covers Send's ordinary and cancellation-driven paths through a real +// gRPC round trip; this file is the one place in this package that needs +// direct access to the unexported newUpdateSender constructor. +type fakeAttachStream struct { + ctx context.Context + sendErr error +} + +func (f *fakeAttachStream) Send(*widgetv1.WidgetUpdate) error { return f.sendErr } +func (f *fakeAttachStream) SetHeader(metadata.MD) error { return nil } +func (f *fakeAttachStream) SendHeader(metadata.MD) error { return nil } +func (f *fakeAttachStream) SetTrailer(metadata.MD) {} +func (f *fakeAttachStream) Context() context.Context { return f.ctx } +func (f *fakeAttachStream) SendMsg(any) error { return nil } +func (f *fakeAttachStream) RecvMsg(any) error { return nil } + +var _ widgetv1.WidgetService_AttachServer = (*fakeAttachStream)(nil) + +func TestUpdateSender_Send_success(t *testing.T) { + t.Parallel() + + stream := &fakeAttachStream{ctx: t.Context()} + sender := newUpdateSender(stream.ctx, stream) + + if err := sender.Send(Update{}); err != nil { + t.Errorf("Send() = %v, want nil", err) + } +} + +func TestUpdateSender_Send_ctxAlreadyCanceled(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + stream := &fakeAttachStream{ctx: ctx} + sender := newUpdateSender(ctx, stream) + + if err := sender.Send(Update{}); !errors.Is(err, context.Canceled) { + t.Errorf("Send() = %v, want context.Canceled", err) + } +} + +// TestUpdateSender_Send_nonCancellationError covers a stream-level Send +// failure unrelated to cancellation (a broken pipe, a marshal failure) — +// UpdateSender.Send must wrap and return it distinguishably from +// context.Canceled, not silently swallow it as "probably cancellation." +func TestUpdateSender_Send_nonCancellationError(t *testing.T) { + t.Parallel() + + wantErr := errors.New("connection reset") + stream := &fakeAttachStream{ctx: t.Context(), sendErr: wantErr} + sender := newUpdateSender(stream.ctx, stream) + + err := sender.Send(Update{}) + if !errors.Is(err, wantErr) { + t.Errorf("Send() = %v, want wrapping %v", err, wantErr) + } + if errors.Is(err, context.Canceled) { + t.Errorf("Send() = %v, want not context.Canceled", err) + } +} diff --git a/pkg/widget/widget.go b/pkg/widget/widget.go new file mode 100644 index 0000000..23a6027 --- /dev/null +++ b/pkg/widget/widget.go @@ -0,0 +1,110 @@ +package widget + +import ( + "context" + "fmt" + + structpb "google.golang.org/protobuf/types/known/structpb" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// Capabilities is this widget provider's complete capability +// advertisement, returned from GetCapabilities. It MUST be cheap to +// compute and MUST NOT require a network call +// (docs/specifications/frontend/widget-protocol.md#transport) — a +// Provider's GetCapabilities implementation should build this from +// static, already-in-process data, never a fresh RPC or I/O call. +type Capabilities struct { + // Regions this widget intends to contribute to. MUST be set. + Regions []renderv1.Region + // ConfigSchema is this provider's agent.hcl config schema — build it + // with pkg/config.Schema and pkg/config.Attribute, or with this + // package's NewCapabilities. + ConfigSchema *configv1.ConfigSchema + // SupportedHookPoints lets the kernel reject an agent.hcl hook{} + // block naming a point this widget can't serve, at config-load time + // rather than at first dispatch. + SupportedHookPoints []commonv1.HookPoint +} + +// AttachRequest identifies which session's widget instance a Provider's +// Attach method is being asked to serve. Per +// docs/specifications/frontend/widget-protocol.md#transport, one +// AttachRequest maps to exactly one Provider.Attach call and one +// session — never a set of sessions multiplexed on one call. +type AttachRequest struct { + // SessionID is the session this widget instance is attaching to. + SessionID string +} + +// UpdateMode says whether an Update replaces or appends to this widget's +// prior content in its target Region. It exists specifically so the +// replace/append distinction can't be silently inverted the way a bare +// bool parameter invites — UpdateAppend is the zero value, matching the +// wire default (an unset WidgetUpdate.replace means false, i.e. append, +// per widget-protocol.md#transport). +type UpdateMode int + +const ( + // UpdateAppend adds this update's Content alongside whatever this + // widget previously pushed to the same Region, rather than replacing + // it. This is UpdateMode's zero value. + UpdateAppend UpdateMode = iota + // UpdateReplace replaces this widget's prior content in the same + // Region entirely. + UpdateReplace +) + +// String returns "append" or "replace" for the two defined UpdateMode +// values, or "unknown(N)" for any other value. +func (m UpdateMode) String() string { + switch m { + case UpdateAppend: + return "append" + case UpdateReplace: + return "replace" + default: + return fmt.Sprintf("unknown(%d)", int(m)) + } +} + +// Update is one pushed update to this widget's rendered content for one +// session, per docs/specifications/frontend/widget-protocol.md#transport +// (the wire message is WidgetUpdate; this is its domain-side +// representation). Mode governs whether Content replaces or appends to +// this widget's prior content in Region — see UpdateMode's doc comment +// for why that distinction is a named type rather than a bare bool. +type Update struct { + // Region this update places Content into. + Region renderv1.Region + // Content to place — build it with pkg/render. + Content *renderv1.RenderTree + // Mode says whether this update replaces or appends to this widget's + // prior content in Region. + Mode UpdateMode +} + +// Provider is the author-facing interface a widget plugin implements. A +// concrete Provider is handed to NewService, which adapts it to +// widgetv1.WidgetServiceServer. +type Provider interface { + // GetCapabilities returns this widget's regions, config schema, and + // supported hook points. MUST be cheap and MUST NOT make a network + // call (docs/specifications/frontend/widget-protocol.md#transport). + GetCapabilities(ctx context.Context) (Capabilities, error) + // Configure decodes and validates this provider's agent.hcl block, + // already-decoded to config. Return an *Error (or any error) to + // reject it; Service surfaces it as a gRPC status, never echoing a + // received secret back out. + Configure(ctx context.Context, config *structpb.Struct) error + // Attach serves one session's update feed for as long as the kernel + // keeps the stream open, pushing every update through sender.Send. + // ctx is canceled when the kernel closes the stream — ordinary + // control flow, not a failure. Attach SHOULD return promptly once ctx + // is done (returning ctx.Err() is the idiomatic choice) rather than + // treating cancellation as an error condition worth reporting. + Attach(ctx context.Context, req AttachRequest, sender *UpdateSender) error +} diff --git a/pkg/widget/widget_test.go b/pkg/widget/widget_test.go new file mode 100644 index 0000000..3a8cacf --- /dev/null +++ b/pkg/widget/widget_test.go @@ -0,0 +1,40 @@ +package widget_test + +import ( + "testing" + + "github.com/pluggableharness/agent/pkg/widget" +) + +func TestUpdateMode_String(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mode widget.UpdateMode + want string + }{ + {name: "append is the zero value", mode: widget.UpdateAppend, want: "append"}, + {name: "replace", mode: widget.UpdateReplace, want: "replace"}, + {name: "unrecognized value", mode: widget.UpdateMode(99), want: "unknown(99)"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := tt.mode.String(); got != tt.want { + t.Errorf("UpdateMode(%d).String() = %q, want %q", int(tt.mode), got, tt.want) + } + }) + } +} + +func TestUpdateMode_zeroValueIsAppend(t *testing.T) { + t.Parallel() + + var m widget.UpdateMode + if m != widget.UpdateAppend { + t.Errorf("zero value UpdateMode = %v, want UpdateAppend", m) + } +}