Record the Claude SDK inference port in the docs
Two transports behind one port, routed per lane by model prefix; the sidecar as a child process; the oracle as the evaluator's own fold; the stopgap's liabilities named with its exit condition; the operator's credential steps in the smoke runbook. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
4f424a5cfa
commit
0cbbf77284
4 changed files with 208 additions and 23 deletions
|
|
@ -433,6 +433,20 @@ Inference receipts are operational telemetry in a separate store. The kernel
|
|||
does not require a provider, model tier, stage name, output hash, or verifier
|
||||
receipt to authorize world state.
|
||||
|
||||
Every controller lane reaches one `InferencePort`, but that port now routes
|
||||
to either of two transports chosen by the lane's configured model name: the
|
||||
CodexConnector daemon peer, unchanged, or `SdkInferencePort`, a Node child
|
||||
process running the Claude Agent SDK against the Claude subscription while
|
||||
no Codex or Anthropic API budget exists. The SDK route is a named stopgap,
|
||||
not a peer design: its receipt attests only that this process's own child
|
||||
produced this session, not against a party Ghostlight does not control, and
|
||||
a prior round's conversation reaches the model as rendered prose rather than
|
||||
typed turns because the SDK owns the assistant side of its own transcript.
|
||||
Its exit condition is deletion, not extension — once an API budget exists, a
|
||||
Messages-API port is a closer structural match to `InferencePort` (inert
|
||||
`tool_use` blocks, a caller-appended `tool_result`, a real request id, no
|
||||
subprocess) and replaces it behind the same routed port.
|
||||
|
||||
## Action and consequence
|
||||
|
||||
An action proposal names one subject, one executable affordance, exact targets,
|
||||
|
|
|
|||
|
|
@ -108,6 +108,33 @@ commit. Narrative Persona projection and total interpretation, plus pure-agent
|
|||
tool execution, remain controller-side organs to wire onto this boundary; no
|
||||
model call exists inside the world owner.
|
||||
|
||||
### Inference transport
|
||||
|
||||
- **Owner:** `RoutedInferencePort` (`world/sdk_inference.rs`), holding both
|
||||
transports every controller lane shares through one `Arc<dyn InferencePort>`.
|
||||
- **Inputs:** the configured model name on each `ControllerModels` field and
|
||||
`GHOSTLIGHT_SDK_MODEL_PREFIX` (default `claude`) decide which transport a
|
||||
lane's request reaches; there is no separate mode flag.
|
||||
- **Outputs:** one `InferenceOutput` per query from either transport, under
|
||||
the same `PreparedInference`/receipt-digest shape; nothing downstream can
|
||||
tell which transport produced it.
|
||||
- **Derived state:** the two transports are `CodexConnectorInferencePort`
|
||||
(unchanged, a daemon peer over a keyed socket) and `SdkInferencePort`, a
|
||||
Node sidecar (`sidecar/claude-sdk/`) run as a plain child process of the
|
||||
runtime — restarted on fault, holding no query state between queries, no
|
||||
daemon, no listening socket, no CultMesh surface. Every tool result the SDK
|
||||
route hands the model is produced by `ToolResultOracle`, the lane's own
|
||||
evaluator fold lent to the port for one query; the sidecar computes none.
|
||||
- **Forbidden writers:** no credential path is read, copied, or logged by
|
||||
Ghostlight or the sidecar; the sidecar inherits the ambient Claude Code
|
||||
login only. `ControllerRunner::open` takes its ports by injection; there is
|
||||
no `#[cfg(test)]` constructor that builds them itself.
|
||||
- **Cut line:** stopgap by design. `SdkInferencePort`'s two named liabilities
|
||||
(its receipt attests only to its own child process, and a prior round's
|
||||
conversation reaches the model as prose) are retired only by deleting the
|
||||
port for a Messages-API transport once an API budget exists, not by
|
||||
extending it.
|
||||
|
||||
## Frozen pre-rebuild flow
|
||||
|
||||
Campaign creation now has its own pre-world authority:
|
||||
|
|
|
|||
|
|
@ -486,33 +486,118 @@ region-wide or global witness still discards a grouped cell's in-flight work
|
|||
for every constituent it touches, which is the debt this pass explicitly
|
||||
declined to pay.
|
||||
|
||||
### 11. Claude SDK inference port — in design
|
||||
### 11. Claude SDK inference port — landed
|
||||
|
||||
Objective: run Ghostlight's inference lanes on the Claude subscription while
|
||||
no Codex subscription or API budget exists, without moving the harness.
|
||||
|
||||
Current mechanism: one `InferencePort` implementation, the CodexConnector
|
||||
client, behind which every lane derives its exact request and owns its loop;
|
||||
the connector's request types leak into four files.
|
||||
Landed mechanism: two `InferencePort` implementors behind one
|
||||
`RoutedInferencePort`, chosen per lane by the configured model's prefix
|
||||
(`GHOSTLIGHT_SDK_MODEL_PREFIX`, default `claude`) — a `claude`-prefixed model
|
||||
reaches `SdkInferencePort`, anything else reaches the existing
|
||||
`CodexConnectorInferencePort` unchanged. `SdkInferencePort` spawns a Node
|
||||
sidecar (`sidecar/claude-sdk/`, a TypeScript package on
|
||||
`@anthropic-ai/claude-agent-sdk` `0.3.261`, `@msgpack/msgpack` `3.1.3`, and
|
||||
`zod` `4.5.4`, all exact-pinned) as a child process over stdio, framed as a
|
||||
4-byte big-endian length prefix plus `rmp_serde::to_vec_named` bytes. One
|
||||
request is one SDK query with the system prompt replaced, built-ins
|
||||
stripped, settings sources empty, the request's tools registered as
|
||||
in-process SDK tools, a derived turn cap, and the request's model.
|
||||
Execute-through: each tool call the model makes crosses the pipe and is
|
||||
answered by `ToolResultOracle`, one trait with four implementors
|
||||
(`InterpreterOracle`, `OperationalOracle`, `GroupedOracle`,
|
||||
`ElaborationOracle`) — each is the lane's own evaluator fold, extracted
|
||||
whole into a free function (`interpreter_tool_result`,
|
||||
`operational_tool_result`, `grouped_tool_result`, and the already-factored
|
||||
`apply_tool_call`) and replayed over `completed` at construction, so the
|
||||
string handed to the model is the same one re-derivation will recompute.
|
||||
The sidecar computes no tool result itself.
|
||||
|
||||
Intended change: a second port, `SdkInferencePort`, that spawns a Node
|
||||
sidecar (a small TypeScript package in this repo on the Claude Agent SDK)
|
||||
over stdio with length-prefixed MessagePack frames. One request is one SDK
|
||||
query with the system prompt replaced, built-ins stripped, settings sources
|
||||
empty, the request's tools registered as in-process SDK tools, a turn cap,
|
||||
and the request's model. Execute-through: each tool call the model makes is
|
||||
handed back over the pipe and answered with the result Ghostlight's own
|
||||
evaluator would produce, so the SDK loop runs on real results and one query
|
||||
returns as one round. Persona and Projector are single completions. The
|
||||
credential is Claude Code's own; Ghostlight holds none.
|
||||
One free function, `prepare_invocation`, is the sole owner of prepared
|
||||
identity for both ports; `ControllerRunner::open` is injection-only —
|
||||
`with_test_ports` is gone, and `open` takes `Arc<dyn InferencePort>`,
|
||||
`Arc<dyn ControllerWorkStore>`, and `ControllerModels` and nothing
|
||||
transport-specific. `runtime::open_controller` builds `ConnectorBinding`
|
||||
and `SdkBinding` from the environment and calls `open_inference`, then
|
||||
`open_controller_work`, then `ControllerRunner::open`; `open_inference`
|
||||
routes every one of the five `ControllerModels` at open time and refuses a
|
||||
model no configured backend claims (`ControllerOpenError::UnroutableModel`)
|
||||
or a sidecar entry that is not a file
|
||||
(`ControllerOpenError::SdkSidecarMissing`) before the first tick, not after.
|
||||
|
||||
Cut line: no second identity scheme (checkpoints re-derive the same request
|
||||
identity); no daemon, network port, or CultMesh surface for the sidecar; no
|
||||
credential handling in Ghostlight; the connector port is untouched; the
|
||||
port is chosen per lane by the configured model. Stopgap by design: the
|
||||
receipt is the SDK's message, not wire bytes, and that liability is named
|
||||
where the port is defined. Rejected: a Messages-API backend (no API spend);
|
||||
a backend inside CodexConnector (a deliberately isolated Codex fork).
|
||||
Environment: `GHOSTLIGHT_SDK_SIDECAR` (the built sidecar entry path; its
|
||||
absence means no SDK binding, not a default path) and
|
||||
`GHOSTLIGHT_SDK_MODEL_PREFIX` (default `claude`) are read once in
|
||||
`runtime::open_controller`. `GHOSTLIGHT_CONTROLLER_CREDENTIAL` is no longer
|
||||
unconditionally required — an SDK-only configuration reads no credential
|
||||
path at all. `GHOSTLIGHT_WRITE_SIDECAR_FIXTURES` regenerates the checked-in
|
||||
schema and frame fixtures the Rust and TypeScript sides pin against each
|
||||
other; it is a fixture-authoring switch, not a runtime binding.
|
||||
|
||||
Cut line held: no second identity scheme (`prepare_invocation` is the one
|
||||
owner, both ports call it); no daemon, network port, or CultMesh surface for
|
||||
the sidecar (it is a plain child process, restarted on fault, holding no
|
||||
query state between queries); no credential handling in Ghostlight (the
|
||||
sidecar inherits the ambient Claude Code login and this repo reads, copies,
|
||||
forwards, or logs none of it); the connector port and its `execute` are
|
||||
untouched; the port is chosen per lane by the configured model, with no mode
|
||||
flag. Two forks the original design did not anticipate: `ControllerRunner::open`
|
||||
became injection-only rather than keeping a `#[cfg(test)]` twin, because
|
||||
removing the two transport-specific parameters made the twin's only purpose
|
||||
vanish; and the turn cap rides on the oracle (`remaining_rounds`) rather than
|
||||
on `InferencePurpose`, because the elaboration and seed lanes share one
|
||||
purpose but carry different round budgets.
|
||||
|
||||
Named liabilities, stated where `SdkInferencePort` is defined, not hidden in
|
||||
the lowering:
|
||||
|
||||
- The receipt is the SDK's message, not wire bytes. Its identity half is
|
||||
computed from the invocation Ghostlight already holds; its provenance half
|
||||
is a session id and message uuids reported by a child process this port
|
||||
spawned. It attests that this exact request produced this exact SDK
|
||||
session; it does not attest against a party Ghostlight does not control,
|
||||
which is what the connector's receipt does.
|
||||
- A prior round's conversation reaches the model as prose, not as typed
|
||||
turns. The SDK owns the assistant side of its own transcript, so typed
|
||||
`tool_call`/`tool_result` turns from an earlier round cannot be replayed
|
||||
into it; they are rendered as strings under one fixed header and appended
|
||||
to the prompt.
|
||||
- The Zod validation of a tool call's arguments happens in the sidecar
|
||||
before the handler runs, so a model's undecodable arguments never reach
|
||||
Rust as an argument string the oracle can fold over; the sidecar reports
|
||||
a normal dispatched tool call and Rust's own evaluator recomputes the
|
||||
decode failure as an ordinary gap — on the connector path the same
|
||||
failure is a gap recorded by the evaluator directly. Both are gaps; the
|
||||
divergence in exactly which layer detects an undecodable argument can
|
||||
only reach a later round's prompt through the prose transcript.
|
||||
- `error_max_turns` handling is inferred from the SDK's own documentation,
|
||||
not observed: the sidecar is written to catch the throw the TypeScript
|
||||
SDK performs after yielding the error result, assemble the events already
|
||||
collected, and emit a normal `Output` whose receipt records
|
||||
`subtype: "error_max_turns"`, so the lane sees one ordinary round and
|
||||
proceeds rather than faulting. No live query has exercised this path.
|
||||
- Because the turn cap is the lane's *remaining* round budget and the lane
|
||||
still counts rounds even when the cap is not reached, a lane whose model
|
||||
never terminates in one turn can spend a triangular number of turns across
|
||||
a round sequence (seed: 24+23+…, bounded by `SEED_ROUND_BUDGET`) against
|
||||
the connector's flat per-round cost. This is unmeasured: no live run has
|
||||
produced a number to weigh it against.
|
||||
|
||||
What is undone: no live run has exercised the SDK port at all — this
|
||||
machine has neither a Claude Code CLI on PATH nor a stored credential, so
|
||||
the ignored ad-hoc smoke (`notes/local-live-smoke.md`) has never been run
|
||||
against it. Everything above the ignored smoke is proven by scripted-link
|
||||
unit tests and the checked-in schema/frame fixture pairs, not by a real
|
||||
sidecar process talking to a real subscription.
|
||||
|
||||
Exit condition, unchanged from the port's own doc comment: when an
|
||||
`ANTHROPIC_API_KEY` and a budget exist, a Messages-API port is a closer
|
||||
structural match to `InferencePort` than the SDK is — inert `tool_use`
|
||||
blocks, a caller-appended `tool_result`, a real request id, real
|
||||
concurrency, no subprocess — and `SdkInferencePort` is deleted rather than
|
||||
extended, keeping `RoutedInferencePort` and swapping what it routes to.
|
||||
Rejected: a backend inside CodexConnector (a deliberately isolated Codex
|
||||
fork).
|
||||
|
||||
## Subtraction budget
|
||||
|
||||
|
|
|
|||
|
|
@ -140,6 +140,65 @@ the kernel, and each became a rule the tree now carries:
|
|||
The harness fails when no seed patch lands or nobody speaks, so a run that
|
||||
reaches the provider and gets nothing back is a failed run.
|
||||
|
||||
## Claude SDK route
|
||||
|
||||
Not yet run. `world/sdk_inference.rs`'s `SdkInferencePort` gives the same
|
||||
ignored smoke a second transport: a lane whose configured model is
|
||||
`claude`-prefixed (matching `GHOSTLIGHT_SDK_MODEL_PREFIX`, default `claude`)
|
||||
routes to a Node sidecar instead of the CodexConnector, so the road can run
|
||||
on the Claude subscription with no Codex budget. `LiveController` and
|
||||
`ControllerRunner::open` (through `open_inference`) already carry this
|
||||
optional binding; only the operator side and a real run are outstanding.
|
||||
|
||||
**Operator prerequisites, none of which Ghostlight performs:**
|
||||
|
||||
1. Install the Claude Code CLI.
|
||||
2. Sign in with `/login`, or mint a token with `claude setup-token`.
|
||||
3. Verify the login with one plain `claude -p` query.
|
||||
|
||||
The harness never reads, copies, forwards, or logs that credential; the
|
||||
sidecar inherits it from the ambient environment the same way the CLI does.
|
||||
|
||||
**Build the sidecar**, once the CLI login is verified:
|
||||
|
||||
```bash
|
||||
npm --prefix sidecar/claude-sdk install
|
||||
npm run sdk:build
|
||||
```
|
||||
|
||||
`npm run sdk:build` is the root `package.json` alias for
|
||||
`npm --prefix sidecar/claude-sdk run build`, emitting
|
||||
`sidecar/claude-sdk/dist/main.js`.
|
||||
|
||||
**Environment**, in addition to the connector variables above:
|
||||
|
||||
```bash
|
||||
GHOSTLIGHT_SDK_SIDECAR='F:\Projects\Ghostlight\sidecar\claude-sdk\dist\main.js'
|
||||
GHOSTLIGHT_SDK_MODEL_PREFIX=claude # optional; this is already the default
|
||||
```
|
||||
|
||||
`GHOSTLIGHT_SDK_SIDECAR`'s absence means no SDK binding at all — a
|
||||
`claude`-prefixed lane model then fails at open with `UnroutableModel`
|
||||
rather than silently reaching the connector. Any of the five
|
||||
`GHOSTLIGHT_CONTROLLER_*_MODEL` variables can be set to a `claude`-prefixed
|
||||
model (for example `claude-opus-5`) to route that lane through the sidecar;
|
||||
the connector variables (`GHOSTLIGHT_CONTROLLER_CONNECTOR`,
|
||||
`GHOSTLIGHT_CONTROLLER_CREDENTIAL`) are only required for the lanes that
|
||||
still route to the connector, and an all-`claude` configuration needs
|
||||
neither.
|
||||
|
||||
The harness is the same ignored test,
|
||||
`runtime::tests::live_smoke_seeds_then_ticks_a_world_against_the_connector`;
|
||||
its `#[ignore]` reason names both prerequisites this route adds: "requires a
|
||||
vault plus either a running CodexConnector or a built Claude SDK sidecar
|
||||
with an ambient Claude Code login; see `GHOSTLIGHT_SMOKE_*` and
|
||||
`GHOSTLIGHT_SDK_*` environment."
|
||||
|
||||
This route has not been run. This machine has neither the Claude Code CLI on
|
||||
PATH nor a stored credential, so nothing beyond the scripted-link unit tests
|
||||
in `world/sdk_inference.rs` and the checked-in schema/frame fixtures has
|
||||
exercised it.
|
||||
|
||||
## Interrupted cell
|
||||
|
||||
Not yet run. The next road run must show a subject whose room changed mid-turn
|
||||
|
|
|
|||
Loading…
Reference in a new issue