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
2 changes: 1 addition & 1 deletion .claude/rules/determinism.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ formula in the codebase:

## Cost and budget rollup

Cost (`docs/specifications/provider/protocol.md#cost-computation`) and depth
Cost (`docs/specifications/model/protocol.md#cost-computation`) and depth
budget (`docs/specifications/agent-loop/subagents.md#depth-limits`) both roll
up a session tree the same way: computed and persisted at usage-event time,
not recomputed lazily on read. Code that reports a session's total cost or
Expand Down
2 changes: 1 addition & 1 deletion .claude/rules/go-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ api/ .proto sources — buf's module root (see buf.yaml).
pluggableharness/agent/<category>/v1/*.proto one directory per category per protocol version
pkg/ first-class, third-party-consumable Go integration —
the only thing a plugin author needs to import.
<category>/ pkg/provider/, pkg/tool/, pkg/context/, pkg/memory/,
<category>/ 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
Expand Down
18 changes: 9 additions & 9 deletions .claude/rules/grpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ dictated by the specs and MUST match exactly.

| Category | RPC | Shape | Source |
|---|---|---|---|
| Model | `StreamCompletion` | server-streaming + cancellation | `docs/specifications/provider/README.md#transport--lifecycle` (explicitly *not* bidi) |
| Model | `StreamCompletion` | server-streaming + cancellation | `docs/specifications/model/README.md#transport--lifecycle` (explicitly *not* bidi) |
| 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` |
Expand All @@ -28,7 +28,7 @@ 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
parallel non-streaming RPC as a shortcut — `docs/specifications/provider/`
parallel non-streaming RPC as a shortcut — `docs/specifications/model/`
and `docs/specifications/tool/` both make the streaming signature MUST
regardless of whether the underlying vendor API streams.
- **Cancellation is normal control flow, not an error.** When the kernel
Expand All @@ -49,12 +49,12 @@ Canonical mapping (extend per spec, don't invent parallel categories):

| Spec category | `grpc/codes` |
|---|---|
| `context_length_exceeded` (`docs/specifications/provider/conformance.md`) | `codes.ResourceExhausted` |
| `rate_limited` (`docs/specifications/provider/conformance.md`) | `codes.ResourceExhausted` (distinguished by structured detail, not code alone) |
| `overloaded` (`docs/specifications/provider/conformance.md`) | `codes.Unavailable` |
| `auth_error` (`docs/specifications/provider/conformance.md`) | `codes.Unauthenticated` |
| `invalid_request` (`docs/specifications/provider/conformance.md`) | `codes.InvalidArgument` |
| `content_filtered` (`docs/specifications/provider/conformance.md`) | `codes.FailedPrecondition` |
| `context_length_exceeded` (`docs/specifications/model/conformance.md`) | `codes.ResourceExhausted` |
| `rate_limited` (`docs/specifications/model/conformance.md`) | `codes.ResourceExhausted` (distinguished by structured detail, not code alone) |
| `overloaded` (`docs/specifications/model/conformance.md`) | `codes.Unavailable` |
| `auth_error` (`docs/specifications/model/conformance.md`) | `codes.Unauthenticated` |
| `invalid_request` (`docs/specifications/model/conformance.md`) | `codes.InvalidArgument` |
| `content_filtered` (`docs/specifications/model/conformance.md`) | `codes.FailedPrecondition` |
| `process_crashed` (`docs/specifications/tool/conformance.md`'s `ToolErrorCategory`) | `codes.Unavailable` |
| cancellation | `codes.Canceled` — never treated as an application error |
| unmapped/unexpected | `codes.Internal`, never `codes.Unknown` |
Expand All @@ -78,7 +78,7 @@ kernel decide.
## 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/provider/protocol.md#render`,
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
Expand Down
8 changes: 4 additions & 4 deletions .claude/rules/proto.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.agent.<category>.v1;` — `pluggableharness.agent.provider.v1`, `pluggableharness.agent.tool.v1`, `pluggableharness.agent.memory.v1`, `pluggableharness.agent.context.v1`, `pluggableharness.agent.frontend.v1`, `pluggableharness.agent.widget.v1`, `pluggableharness.agent.kernel.v1` (the kernel-callback service). File path mirrors the package under buf's module root (`buf.yaml`'s `api` module): `api/pluggableharness/agent/<category>/v1/<category>.proto`.
- Package per category, per version: `package pluggableharness.agent.<category>.v1;` — `pluggableharness.agent.model.v1`, `pluggableharness.agent.tool.v1`, `pluggableharness.agent.memory.v1`, `pluggableharness.agent.context.v1`, `pluggableharness.agent.frontend.v1`, `pluggableharness.agent.widget.v1`, `pluggableharness.agent.kernel.v1` (the kernel-callback service). File path mirrors the package under buf's module root (`buf.yaml`'s `api` module): `api/pluggableharness/agent/<category>/v1/<category>.proto`.
- `option go_package = "github.com/pluggableharness/agent/pkg/<category>/proto/v1;<category>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/<category>/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/agent/...`) 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.

Expand All @@ -28,7 +28,7 @@ strongly typed as the Go code that implements it.
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/provider/protocol.md` and `docs/specifications/frontend/render-tree.md`
`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.
- No untyped `map<string, string>` or `map<string, bytes>` standing in for a
Expand Down Expand Up @@ -56,7 +56,7 @@ strongly typed as the Go code that implements it.
that implements it.
- Every `service` has a comment naming which `docs/specifications/` document
it implements, e.g. `// ModelService implements the model provider
protocol described in docs/specifications/provider/protocol.md.`
protocol described in docs/specifications/model/protocol.md.`

## Versioning — no breaking changes

Expand All @@ -69,6 +69,6 @@ strongly typed as the Go code that implements it.
work: an old session must always be decodable by a client built against a
newer proto, because the field numbers and types of a released message
never change.
- A breaking change is shipped as a new package version (`pluggableharness.agent.provider.v2`), never as an edit to `v1`. It lands at `api/pluggableharness/agent/provider/v2/` and generates into `pkg/provider/proto/v2/`, alongside — never replacing — `v1`. The old `v1` service stays defined and generated as long as any retained session was produced by a `v1` plugin.
- A breaking change is shipped as a new package version (`pluggableharness.agent.model.v2`), never as an edit to `v1`. It lands at `api/pluggableharness/agent/model/v2/` and generates into `pkg/model/proto/v2/`, alongside — never replacing — `v1`. The old `v1` service stays defined and generated as long as any retained session was produced by a `v1` plugin.
- Field numbers are never reused, even for removed fields — `reserved N;`
and `reserved "field_name";` on removal.
34 changes: 23 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,12 +176,15 @@ jobs:
#
# 1. buf lint style/naming per buf.yaml (STANDARD rule set)
# 2. buf format formatting drift in .proto sources
# 3. buf breaking wire-compat vs the PR base branch (FILE rule set).
# Plugins are versioned artifacts resolved at runtime —
# an unnoticed contract break strands every provider
# built against the old wire format. An intentional
# break must be visible: this check forces the PR to
# say so.
# 3. buf breaking wire-compat vs the latest v* release tag (FILE rule
# set), per .claude/rules/proto.md's policy: the
# permanence guarantee applies from the first released
# tag onward, not from repo inception. Skipped entirely
# when no v* tag exists yet. Plugins are versioned
# artifacts resolved at runtime — an unnoticed contract
# break strands every provider built against the old
# wire format. An intentional break must be visible:
# this check forces the PR to say so.
# 4. drift check pkg/*/proto/v1 is 100% derived output and must match
# what the pinned generators produce from api/ exactly.
# Catches both hand-edited .pb.go files and .proto
Expand Down Expand Up @@ -216,13 +219,22 @@ jobs:
- name: Format check
run: buf format --diff --exit-code

# The base ref isn't fetched on a shallow PR checkout — fetch it into
# a real remote-tracking ref so buf can diff against it.
- name: Breaking-change check (vs base branch)
# Compares against the latest v* release tag, not the PR base branch —
# .claude/rules/proto.md's wire-compat guarantee is scoped to "any v1
# (or later-released) package", not to every commit since repo
# inception. A shallow PR checkout doesn't have tags by default, so
# fetch them explicitly.
- name: Breaking-change check (vs latest release tag)
if: github.event_name == 'pull_request'
run: |
git fetch origin "${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}"
buf breaking --against ".git#ref=refs/remotes/origin/${{ github.base_ref }}"
git fetch --tags origin
latest_tag="$(git tag -l 'v*' --sort=-v:refname | head -n1)"
if [ -z "$latest_tag" ]; then
echo "No v* release tag exists yet — skipping buf breaking (proto.md: the wire-compat guarantee applies from the first released tag onward)."
exit 0
fi
echo "Comparing against $latest_tag"
buf breaking --against ".git#tag=$latest_tag"

# Generator versions are pinned by go.mod `tool` directives, so this
# regeneration is byte-for-byte reproducible. Binaries go to bin/ per
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`provider/` = 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) 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 |
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ GOBIN=$PWD/bin go install tool # generators pinned via go.mo
PATH=$PWD/bin:$PATH buf generate # regenerate pkg/*/proto/v1 — commit the result
```

CI additionally runs `buf breaking` against the PR base branch — a wire-contract break fails the build by design; if it's intentional, say so explicitly in the PR.
CI additionally runs `buf breaking` against the latest `v*` release tag (skipped when no tag exists yet) — a wire-contract break fails the build by design; if it's intentional, say so explicitly in the PR.

Ground rules the gate can't fully check:

Expand All @@ -51,7 +51,7 @@ Ground rules the gate can't fully check:

`docs/specifications/` has two hard editorial rules, defined in [`docs/specifications/conventions.md`](docs/specifications/conventions.md):

- Cross-references are relative path + heading anchor (`[cost computation](provider/protocol.md#cost-computation)`), never section numbers. Before renaming a heading, grep the tree for its anchor — inbound links break silently.
- Cross-references are relative path + heading anchor (`[cost computation](model/protocol.md#cost-computation)`), never section numbers. Before renaming a heading, grep the tree for its anchor — inbound links break silently.
- Fix-forward: the docs describe the system as it is. Corrections are written as current, unqualified truth — no strikethrough, no "previously this said" narrative.

All Markdown in this repo is GitHub Flavored Markdown with one unwrapped line per paragraph — no hard-wrapping at a fixed column.
Expand Down
66 changes: 64 additions & 2 deletions api/pluggableharness/agent/common/v1/common.proto
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ enum Category {
// Zero value. Never valid for a real producer; its presence on the wire
// means a caller forgot to set the field.
CATEGORY_UNSPECIFIED = 0;
// A model (LLM vendor) provider — specifications/provider.md.
CATEGORY_PROVIDER = 1;
// A model (LLM vendor) provider — specifications/model.md.
CATEGORY_MODEL = 1;
// A tool provider — specifications/tool.md.
CATEGORY_TOOL = 2;
// A context provider — specifications/context.md.
Expand All @@ -50,6 +50,48 @@ enum Category {
CATEGORY_WIDGET = 6;
}

// HookPoint identifies one of the eight dispatchable points in the agent
// loop (agent-loop/hook-dispatch.md; architecture.md §Hook dispatch
// semantics enumerates the nine hook-point names, of which
// context-assemble is deliberately excluded — it stays on
// ContextService.Contribute rather than the hook.v1 dispatch surface).
// Lives here rather than in hook.v1 because hook.v1 imports several
// category packages (model, tool, plan, session) for its typed payloads:
// a category package advertising `supported_hook_points` in its
// capability message could not import hook.v1 back without a cycle, and
// this file is the import-nothing leaf every package may depend on —
// the same reasoning that homes Category here. Used by hook.v1's
// HookError, the event.v1 hook-error payload, and every category's
// capability advertisement.
enum HookPoint {
// Zero value. Never valid on the wire; its presence means a caller
// forgot to set the field.
HOOK_POINT_UNSPECIFIED = 0;
// Session creation, before the first turn begins.
HOOK_POINT_SESSION_START = 1;
// Immediately before a model provider's StreamCompletion is called.
HOOK_POINT_PRE_MODEL_CALL = 2;
// Immediately after a model turn's canonical message has been
// assembled from the completion stream.
HOOK_POINT_POST_MODEL_RESPONSE = 3;
// Immediately before a plan item's tool call is applied.
HOOK_POINT_PRE_TOOL_CALL = 4;
// Once a turn's Plan has been fully built, before plan/apply gate
// dispatch. The kernel-privileged policy veto subscriber
// (architecture.md §Policy — first-party, not a plugin category) always
// runs at this point.
HOOK_POINT_PLAN_READY = 5;
// Immediately after a plan item's tool call has produced a terminal
// ToolResult or ToolError.
HOOK_POINT_POST_TOOL_CALL = 6;
// Immediately after a turn's whole Plan has finished applying (every
// item reached a terminal ApplyOutcome).
HOOK_POINT_POST_APPLY = 7;
// Session termination, once the session has reached a terminal
// SessionStatus.
HOOK_POINT_SESSION_END = 8;
}

// ProducerRef identifies the exact plugin build that produced something: a
// specific name, version, and source, pinned to the category it
// implements. This is the wire-level counterpart of the "supersedes"
Expand Down Expand Up @@ -98,3 +140,23 @@ message ProviderRef {
// not globally — matches ProducerRef.name's uniqueness scope.
string name = 2;
}

// CallContext identifies the session, turn, and working directory a given
// RPC call executes for. Attached to a model provider's
// StreamCompletionRequest and a tool provider's ToolCall in this protocol
// revision (forthcoming in those files) — it's what a plugin passes back
// on its own KernelCallbackService.Emit call (kernel-callbacks.md §Emit)
// for correlation, without having to separately thread session_id/turn_id
// through every call site by hand.
message CallContext {
// The session this call executes for. ULID-formatted, per this file's
// ID conventions above (§"ID and timestamp conventions") — the same
// session_id a plugin passes to Emit.
string session_id = 1;

// The turn within that session this call executes for. ULID-formatted.
string turn_id = 2;

// The session's working directory at call time.
string working_directory = 3;
}
Loading