Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .claude/rules/go-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ pkg/ first-class, third-party-consumable Go integration —
the tool/model JSON-Schema subset, ContentBlock)
that every category SDK composes rather than
reimplementing per category.
sse/ shared vendor-neutral plumbing: SSE frame
decoding, which every model provider needs and
none of them should rewrite. Not a builder and
not tied to one category — the test for belonging
here is that a third-party plugin author would
otherwise copy it out of another provider.
docs/specifications/ protocol contracts (already exists, authoritative)
```

Expand Down
30 changes: 26 additions & 4 deletions .claude/rules/plugin-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,32 @@ covers RPC shape and `proto.md` covers wire typing.
magic cookie key/value and a `ProtocolVersion` field. Do not give
categories different cookies — the uniform handshake is what lets the
kernel reject a mismatched-protocol plugin before ever calling into it.
- `ProtocolVersion` is bumped only on a breaking wire change (see `proto.md`'s
`buf breaking` rule) — bumping it and shipping a `v1`→`v2` proto package
bump happen together, never independently.
- The kernel-side plugin client always checks the negotiated protocol
- **Two different versions exist, and they move independently. Do not couple them.**
- `pkg/common.ProtocolVersion` is the go-plugin **handshake** version. It
versions the *runtime* contract only — the handshake, the fixed callback
broker id, and how services are muxed onto one connection. Bump it only
when one of those changes.
- Each category SDK's own `ProtocolVersion` constant (e.g.
`pkg/model.ProtocolVersion`) is that **category's** protocol version —
the `v1` in `pluggableharness.model.v1`. It is bumped alongside a
`v1`→`v2` proto package bump for that category, and for no other reason.

The separation is load-bearing. A handshake-version mismatch rejects a
plugin *before any category RPC is issued*, so folding category versions
into the handshake would mean a breaking change in one category forced
every plugin of every other category ever published to rebuild in order
to keep working. An earlier revision of `common.v1.ProducerRef`'s comment
said a handshake bump "always accompanies a proto package version bump
for that category"; that was the coupling, and it is no longer the rule.
- **A category's version is already part of its gRPC service name**, so
correctness does not depend on any negotiation field: a plugin serving
`pluggableharness.model.v2.ModelService` and a kernel dispensing `v1`
simply do not match. `ProducerRef.protocol_version` exists so that
mismatch surfaces as a clear version error at bring-up rather than as an
opaque "unimplemented service" on the first real call — and so a lock
file recording it lets `preflightVersionCheck` reject a plugin before
spawning it at all.
- The kernel-side plugin client always checks the negotiated handshake
version before issuing the first category RPC; a mismatch is a startup
error, not a runtime error discovered on first call.

Expand Down
25 changes: 22 additions & 3 deletions .claude/rules/proto.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,29 @@ strongly typed as the Go code that implements it.
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`
not the kernel's to name), `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.
reasoning as `log.v1.LogEntry.fields`), and
`model.v1.StreamCompletionRequest.provider_options` (vendor-specific
request knobs the kernel has no semantics for — the same "shape is the
provider's, not the kernel's to name" reasoning as `ConfigureRequest.config`,
applied per-request rather than once at configure time) are the
precedents. A field whose keys are actually fixed and enumerable belongs
in a real message instead.
- **A `Struct` field is pass-through only: if the kernel reads it, it must
be a typed field instead.** This is what keeps the precedent list above
from becoming a general-purpose escape from the strong-typing rule. Every
entry on it is data the kernel *carries* — logs it, relays it, hands it
to the plugin that owns it — never data the kernel *branches on*. The
moment a value affects kernel behavior (routing, capability validation,
cost computation, replay), a `Struct` is the wrong home for it, because
nothing about a `Struct` key is discoverable, validatable, or versionable
at the wire contract level. `model.v1`'s prompt-cache TTL is the worked
example: it looks like an ordinary vendor knob, but the kernel computes
`cost_usd` and the TTL changes the rate, so it cannot live in
`provider_options` and must be a typed field or not exist
(`docs/specifications/model/conformance.md`'s open questions records why
it is currently the latter).
- 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`
Expand Down
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,40 @@ jobs:
- name: Vet
run: go vet ./...

# pkg/ is the third-party plugin-author surface, consumed from
# outside this module where internal/ is unreachable by definition.
# An internal/ import there compiles fine here and fails for every
# downstream author, so it has to be caught on this side.
#
# pkg/telemetry is the one sanctioned exception, documented in its
# own source: it wraps internal/telemetry but keeps its exported
# surface expressible outside this module.
- name: Verify pkg/ does not import internal/
run: |
if grep -rn --include='*.go' 'pluggableharness/agent/internal' pkg/ \
| grep -v '^pkg/telemetry/'; then
echo "::error::pkg/ must not import internal/ — see the exception note in pkg/telemetry"
exit 1
fi

# Builds the example provider from its own module, against this
# commit via a replace directive. This is the only check that proves
# pkg/ is genuinely usable from outside the main module: the
# depguard rule on internal/anthropic only simulates that isolation,
# and a simulation cannot catch an unexported type leaking through
# an exported signature.
- name: Build the standalone example provider
working-directory: examples/provider
run: |
go mod tidy
git diff --exit-code -- go.mod go.sum
go build ./...
go vet ./...
# Also runs the example's own conformance test, which proves
# pkg/model/modeltest is reachable and usable by a third party —
# the premise of shipping a conformance suite in pkg/ at all.
go test ./...

# ---------------------------------------------------------------------------
# test — race-enabled tests on every platform we release for.
#
Expand Down
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@
*.test
__debug_bin*

# A bare `go build ./...` names its output after the package and drops it in
# the working directory, which the extension patterns above do not catch on
# Linux or macOS. These are the ones this repo can actually produce, listed
# by name because a broad pattern here would ignore real source files.
/agent
/anthropic
/tui
/providerconform
examples/*/agent-example-provider

# --- Go: test, coverage & profiling artifacts --------------------------------
*.out
coverage.txt
Expand Down
23 changes: 19 additions & 4 deletions api/pluggableharness/common/v1/types.proto
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,25 @@ message ProducerRef {
// Which of the seven plugin categories this producer implements.
Category category = 4;

// The go-plugin handshake protocol version this producer build was
// compiled against (.claude/rules/plugin-runtime.md). A version bump
// here always accompanies a proto package version bump (v1 -> v2) for
// that category — it never happens independently of one.
// The version of THIS PRODUCER'S OWN CATEGORY protocol it implements —
// the "v1" in pluggableharness.<category>.v1, reported per category
// rather than globally.
//
// Deliberately NOT the go-plugin handshake version, which versions the
// runtime contract (handshake, callback broker, service muxing) that
// every category shares. The two move independently: coupling them
// would mean a breaking change in one category forced every plugin of
// every OTHER category to rebuild, because a handshake-version bump
// rejects a plugin before any category RPC is issued.
//
// Correctness does not depend on this field — a category's proto
// package version is part of its gRPC service name, so a plugin serving
// pluggableharness.model.v2.ModelService and a kernel dispensing v1
// simply do not match. It exists so that mismatch is reported as a
// clear version error at bring-up rather than as an opaque
// "unimplemented service" on the first real call, and so a lock file
// recording it lets the kernel reject a plugin before spawning it at
// all (.claude/rules/plugin-runtime.md).
uint32 protocol_version = 5;
}

Expand Down
18 changes: 18 additions & 0 deletions api/pluggableharness/model/v1/events.proto
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ message StreamEvent {
// A complete, vendor-encrypted reasoning block the kernel cannot
// interpret.
RedactedThinking redacted_thinking = 10;
// The vendor has accepted the request and named it.
StreamStart stream_start = 11;
}

// StreamStart carries the vendor's own identifier for this request, as
// soon as the adapter learns it — normally from response headers,
// before any content streams.
//
// It exists so a failure can be correlated with the vendor's own logs
// when asking them what went wrong. That is why it is a separate event
// emitted early rather than a field on Stop: an id that only arrives on
// successful completion is absent in exactly the case it is needed.
// MAY be omitted entirely by a vendor that publishes no such id.
message StreamStart {
// The vendor's request identifier, verbatim (an Anthropic
// `request-id` header, an OpenAI `x-request-id`). Opaque to the
// kernel: logged and surfaced, never parsed.
string provider_request_id = 1;
}

// TextDelta carries one incremental fragment of assistant text output.
Expand Down
60 changes: 56 additions & 4 deletions api/pluggableharness/model/v1/rpc_request.proto
Original file line number Diff line number Diff line change
Expand Up @@ -90,18 +90,70 @@ message StreamCompletionRequest {
// natural stable-prefix boundaries — see
// model/protocol.md#cache-breakpoint-placement-policy.
repeated CacheBreakpoint cache_breakpoints = 7;

// Vendor-specific request knobs the kernel has no semantics for, passed
// through untouched: the kernel never reads a key, validates one, or
// assigns meaning to one. This is the escape hatch that lets a
// third-party provider ship a vendor feature — a service tier, a
// sampling seed, a beta-feature flag, a conversation-retention id —
// without a change to this protocol. Values originate in the provider's
// own ConfigSchema and the operator's provider{} block, and the provider
// documents its own accepted keys; two providers MAY use the same key
// name for unrelated things.
//
// A Struct for the same reason ConfigureRequest.config is one — the
// shape is the provider's schema, not the kernel's to name — applied
// per-request rather than once at configure time (.claude/rules/proto.md's
// Struct precedent list).
//
// MUST NOT carry anything the kernel reads. Pass-through is the whole
// contract, so a value affecting routing, capability validation, cost
// computation, or replay is a typed field on this protocol or it does
// not work at all — a provider smuggling one through here gets silence,
// not kernel behavior. Promoting such a knob to a typed field in a later
// revision is the fix; teaching the kernel to read this field is not.
// See model/data-types.md#provider_options.
optional google.protobuf.Struct provider_options = 8;
}

// CountTokensRequest is CountTokens' request: the raw text to count, per
// model.md §2.1.
// CountTokensRequest is CountTokens' request: the request whose input
// tokens are being counted, per model/protocol.md#counttokens.
//
// This mirrors StreamCompletionRequest's content-bearing fields, minus
// everything that only affects generation, because every vendor exposing
// exact counting counts a whole request rather than a string — Anthropic's
// /v1/messages/count_tokens takes messages plus system plus tools. An
// earlier revision carried a flat `text` field; concatenating text and
// discarding the rest undercounts by the entire tool-schema and
// system-preamble weight, which is exactly the weight that decides whether
// a turn fits in the context window.
message CountTokensRequest {
// The text to count tokens for.
string text = 1;
reserved 1;

reserved "text";

// 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;

// The conversation to count, in emission order. MAY be empty. A caller
// with only loose content to measure (a context provider sizing its own
// contribution, kernel-callbacks.md#counttokens) passes it as a single
// user message — which is what the adapter would have had to construct
// anyway.
repeated pluggableharness.content.v1.Message messages = 3;

// The kernel-assembled context chain that would accompany these
// messages, counted against the same vendor mechanism
// StreamCompletionRequest.assembled_context maps to. MAY be empty.
repeated pluggableharness.content.v1.ContextSection assembled_context = 4;

// The tool declarations that would accompany these messages. MAY be
// empty. Tool schemas are frequently the largest single contributor to
// a request's input tokens, so omitting them is the main way a count
// goes badly wrong.
repeated ToolDeclaration tools = 5;
}

// RenderRequest carries the opaque payload to render, per model.md §7.
Expand Down
Loading