diff --git a/docs/architecture/ghostlight-dungeon-mvp.md b/docs/architecture/ghostlight-dungeon-mvp.md index 61ca67d..da0d49c 100644 --- a/docs/architecture/ghostlight-dungeon-mvp.md +++ b/docs/architecture/ghostlight-dungeon-mvp.md @@ -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, diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index f0b3002..cf08da0 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -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`. +- **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: diff --git a/notes/ghostlight-implementation-plan.md b/notes/ghostlight-implementation-plan.md index 420da2b..f0da086 100644 --- a/notes/ghostlight-implementation-plan.md +++ b/notes/ghostlight-implementation-plan.md @@ -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`, +`Arc`, 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 diff --git a/notes/local-live-smoke.md b/notes/local-live-smoke.md index cc4a69a..02ab75f 100644 --- a/notes/local-live-smoke.md +++ b/notes/local-live-smoke.md @@ -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