Continue the conversation on refusal, and give the claude lane its descriptions
The first SDK-backed seeded runs. Run 1 refused both seed sessions: the
sidecar's schema conversion dropped every field description, `due` was an
undescribed integer against a clock the model never saw, and the subject
tool did not say a grant is required. Run 2 seeded six people in one round
and ticked three times.
A refusal used to reopen the session with an empty draft and a prompt that
named sites from a draft the model could no longer see. It now continues
the same conversation: `Refusal { after_round, mismatches }` on both
authoring checkpoints (controller_work.v12), the refusal as the next user
turn, and the draft rebuilt by one DraftFold shared by evaluator and oracle
from the calls after it. The harness takes the connector only when its
address is set, so an all-claude run needs none.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
463bd8dc14
commit
936aa71b8d
12 changed files with 546 additions and 235 deletions
|
|
@ -2470,9 +2470,9 @@ mod tests {
|
|||
/// A real CodexConnector behind the fixture, read from the environment
|
||||
/// the production runtime reads. Only the ignored live smoke uses it.
|
||||
struct LiveController {
|
||||
endpoint: SocketAddr,
|
||||
credential: PathBuf,
|
||||
runtime_id: String,
|
||||
/// Absent when every lane routes to the sidecar; `open_inference`
|
||||
/// refuses a model neither transport can carry.
|
||||
connector: Option<ConnectorBinding>,
|
||||
/// Present only when the smoke is run against the Claude SDK sidecar
|
||||
/// instead of, or alongside, the connector.
|
||||
sdk: Option<SdkBinding>,
|
||||
|
|
@ -2503,9 +2503,11 @@ mod tests {
|
|||
let controller_key = directory.path().join("controller.key");
|
||||
std::fs::write(&controller_key, "runtime-test-controller-key").unwrap();
|
||||
let live = live.unwrap_or_else(|| LiveController {
|
||||
endpoint: "127.0.0.1:9".parse().unwrap(),
|
||||
credential: controller_key.clone(),
|
||||
runtime_id: "ghostlight-runtime-test".into(),
|
||||
connector: Some(ConnectorBinding {
|
||||
endpoint: "127.0.0.1:9".parse().unwrap(),
|
||||
key_path: controller_key.clone(),
|
||||
caller_runtime_id: "ghostlight-runtime-test".into(),
|
||||
}),
|
||||
sdk: None,
|
||||
models: ControllerModels {
|
||||
projector: "gpt-5.6-luna".into(),
|
||||
|
|
@ -2515,16 +2517,7 @@ mod tests {
|
|||
elaborator: "gpt-5.6-terra".into(),
|
||||
},
|
||||
});
|
||||
let inference = open_inference(
|
||||
Some(ConnectorBinding {
|
||||
endpoint: live.endpoint,
|
||||
key_path: live.credential.clone(),
|
||||
caller_runtime_id: live.runtime_id.clone(),
|
||||
}),
|
||||
live.sdk,
|
||||
&live.models,
|
||||
)
|
||||
.unwrap();
|
||||
let inference = open_inference(live.connector, live.sdk, &live.models).unwrap();
|
||||
let work = open_controller_work(directory.path().join("controller-work.cc")).unwrap();
|
||||
let controllers =
|
||||
ControllerRunner::open(world.clone(), inference, work, live.models).unwrap();
|
||||
|
|
@ -3625,9 +3618,13 @@ mod tests {
|
|||
let env = |name: &str| std::env::var(name).unwrap_or_else(|_| panic!("{name} is required"));
|
||||
let runtime_id = env("GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID");
|
||||
let live = LiveController {
|
||||
endpoint: env("GHOSTLIGHT_CONTROLLER_CONNECTOR").parse().unwrap(),
|
||||
credential: PathBuf::from(env("GHOSTLIGHT_CONTROLLER_CREDENTIAL")),
|
||||
runtime_id: runtime_id.clone(),
|
||||
connector: std::env::var("GHOSTLIGHT_CONTROLLER_CONNECTOR")
|
||||
.ok()
|
||||
.map(|endpoint| ConnectorBinding {
|
||||
endpoint: endpoint.parse().unwrap(),
|
||||
key_path: PathBuf::from(env("GHOSTLIGHT_CONTROLLER_CREDENTIAL")),
|
||||
caller_runtime_id: runtime_id.clone(),
|
||||
}),
|
||||
sdk: std::env::var_os("GHOSTLIGHT_SDK_SIDECAR")
|
||||
.map(PathBuf::from)
|
||||
.map(|sidecar_entry| SdkBinding {
|
||||
|
|
|
|||
|
|
@ -72,8 +72,8 @@ pub(super) const CELL_TOOL_STEP_BUDGET: usize = 2;
|
|||
/// carried by tool identity, never by a model-written argument.
|
||||
const HANDLE_SEPARATOR: &str = "__";
|
||||
const PERSONA_WORD_BUDGET: usize = 180;
|
||||
const CONTROLLER_WORK_ROW: &str = "controller_work.v11";
|
||||
const CONTROLLER_WORK_SCHEMA: &str = "ghostlight.controller_work.v11";
|
||||
const CONTROLLER_WORK_ROW: &str = "controller_work.v12";
|
||||
const CONTROLLER_WORK_SCHEMA: &str = "ghostlight.controller_work.v12";
|
||||
|
||||
/// The Interpreter's byte-span capture tool. It is not the generated `speak`
|
||||
/// affordance tool: one captures an utterance out of preserved prose, the other
|
||||
|
|
@ -704,9 +704,9 @@ impl ControllerWork {
|
|||
}
|
||||
Self::Elaboration(ElaborationCheckpoint::ElaboratorInFlight {
|
||||
completed,
|
||||
last_mismatches,
|
||||
refusals,
|
||||
..
|
||||
}) => completed.is_empty() && last_mismatches.is_empty(),
|
||||
}) => completed.is_empty() && refusals.is_empty(),
|
||||
Self::Seed(checkpoint) => checkpoint.is_initial(),
|
||||
_ => false,
|
||||
}
|
||||
|
|
@ -8741,32 +8741,41 @@ mod tests {
|
|||
|
||||
// The kernel's complete set is what the checkpoint carries, under the
|
||||
// same command id, and it is not empty.
|
||||
let (command_id, mismatches, resumed_prompt) = {
|
||||
// The refusal keeps the round that earned it and follows it as the
|
||||
// next user turn, so the sites it names are calls the model can see.
|
||||
let (command_id, mismatches, resumed_input) = {
|
||||
let stored = store.work.lock().unwrap();
|
||||
assert_eq!(stored.len(), 1, "one session, one row");
|
||||
let ControllerWork::Elaboration(ElaborationCheckpoint::ElaboratorInFlight {
|
||||
command_id,
|
||||
last_mismatches,
|
||||
agent_prompt,
|
||||
refusals,
|
||||
completed,
|
||||
invocation,
|
||||
..
|
||||
}) = stored.values().next().unwrap().clone()
|
||||
else {
|
||||
panic!("the rejection did not reopen the session for repair");
|
||||
};
|
||||
assert!(!last_mismatches.is_empty(), "the repair set is empty");
|
||||
assert!(completed.is_empty(), "a rejected round kept its evidence");
|
||||
(command_id, last_mismatches, agent_prompt)
|
||||
assert_eq!(completed.len(), 1, "the refused round was not kept");
|
||||
assert_eq!(refusals.len(), 1, "one refusal reopens the session");
|
||||
assert_eq!(refusals[0].after_round, 1);
|
||||
assert!(!refusals[0].mismatches.is_empty(), "the repair set is empty");
|
||||
(
|
||||
command_id,
|
||||
refusals[0].mismatches.clone(),
|
||||
serde_json::to_string(&invocation.invocation.request.input).unwrap(),
|
||||
)
|
||||
};
|
||||
assert!(
|
||||
resumed_prompt.contains("Your previous patch was refused"),
|
||||
"{resumed_prompt}"
|
||||
resumed_input.contains("The world refused the patch you submitted"),
|
||||
"{resumed_input}"
|
||||
);
|
||||
for mismatch in &mismatches {
|
||||
let rendered = serde_json::to_string(mismatch).unwrap();
|
||||
let rendered = serde_json::to_string(&serde_json::to_string(mismatch).unwrap()).unwrap();
|
||||
let rendered = rendered.trim_matches('"');
|
||||
assert!(
|
||||
resumed_prompt.contains(&rendered),
|
||||
"the round-two prompt dropped {rendered}"
|
||||
resumed_input.contains(rendered),
|
||||
"the round-two conversation dropped {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -8801,11 +8810,12 @@ mod tests {
|
|||
);
|
||||
|
||||
// The wire agrees with the checkpoint: the second invocation carried
|
||||
// the repaired prompt.
|
||||
// the refused round and the refusal turn after it.
|
||||
let seen = script.seen.lock().unwrap();
|
||||
assert_eq!(seen.len(), 2, "two rounds, two invocations");
|
||||
let second_wire = serde_json::to_string(&seen[1]).unwrap();
|
||||
assert!(second_wire.contains("previous patch was refused"));
|
||||
assert!(second_wire.contains("The world refused the patch you submitted"));
|
||||
assert!(second_wire.contains("The Roadside Shed"), "the refused round left the wire");
|
||||
|
||||
drop(second);
|
||||
drop(mailbox);
|
||||
|
|
@ -10458,26 +10468,32 @@ mod tests {
|
|||
assert_eq!(first.step().await.unwrap(), SeedOutcome::Rejected);
|
||||
drop(first);
|
||||
|
||||
let (command_id, prompt) = {
|
||||
let (command_id, prompt, input) = {
|
||||
let stored = store.work.lock().unwrap();
|
||||
assert_eq!(stored.len(), 1, "one session, one row");
|
||||
let ControllerWork::Seed(SeedCheckpoint::SeedInFlight {
|
||||
command_id,
|
||||
last_mismatches,
|
||||
refusals,
|
||||
agent_prompt,
|
||||
completed,
|
||||
invocation,
|
||||
..
|
||||
}) = stored.values().next().unwrap().clone()
|
||||
else {
|
||||
panic!("the rejection did not reopen the session for repair");
|
||||
};
|
||||
assert!(!last_mismatches.is_empty(), "the repair set is empty");
|
||||
assert!(completed.is_empty(), "a rejected round kept its evidence");
|
||||
(command_id, agent_prompt)
|
||||
assert_eq!(refusals.len(), 1, "one refusal reopens the session");
|
||||
assert!(!refusals[0].mismatches.is_empty(), "the repair set is empty");
|
||||
assert_eq!(completed.len(), 1, "the refused round was not kept");
|
||||
(
|
||||
command_id,
|
||||
agent_prompt,
|
||||
serde_json::to_string(&invocation.invocation.request.input).unwrap(),
|
||||
)
|
||||
};
|
||||
assert!(
|
||||
prompt.contains("Your previous patch was refused"),
|
||||
"{prompt}"
|
||||
input.contains("The world refused the patch you submitted"),
|
||||
"{input}"
|
||||
);
|
||||
assert!(prompt.contains("You are seeding a world before it opens"));
|
||||
assert!(prompt.contains("holds a goal commitment"));
|
||||
|
|
@ -11408,28 +11424,28 @@ mod tests {
|
|||
command_id,
|
||||
session,
|
||||
agent_prompt,
|
||||
last_mismatches,
|
||||
refusals,
|
||||
completed,
|
||||
invocation,
|
||||
} = repair.clone()
|
||||
else {
|
||||
panic!("a rejection must reopen the session in flight");
|
||||
};
|
||||
assert!(!last_mismatches.is_empty());
|
||||
assert!(completed.is_empty());
|
||||
assert_eq!(refusals.len(), 1);
|
||||
assert_eq!(completed.len(), 1);
|
||||
|
||||
let ready = SeedCheckpoint::ReadyToSubmit {
|
||||
command_id,
|
||||
session: session.clone(),
|
||||
agent_prompt: agent_prompt.clone(),
|
||||
last_mismatches: last_mismatches.clone(),
|
||||
refusals: refusals.clone(),
|
||||
completed: completed.clone(),
|
||||
};
|
||||
let unrepaired = SeedCheckpoint::SeedInFlight {
|
||||
command_id,
|
||||
session: session.clone(),
|
||||
agent_prompt: agent_prompt.clone(),
|
||||
last_mismatches: Vec::new(),
|
||||
refusals: Vec::new(),
|
||||
completed: completed.clone(),
|
||||
invocation: invocation.clone(),
|
||||
};
|
||||
|
|
@ -11437,6 +11453,7 @@ mod tests {
|
|||
command_id,
|
||||
session: session.clone(),
|
||||
agent_prompt: agent_prompt.clone(),
|
||||
refusals: refusals.clone(),
|
||||
completed: completed.clone(),
|
||||
gaps: Vec::new(),
|
||||
};
|
||||
|
|
@ -11448,7 +11465,7 @@ mod tests {
|
|||
moved
|
||||
},
|
||||
agent_prompt,
|
||||
last_mismatches,
|
||||
refusals,
|
||||
completed,
|
||||
invocation,
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1162,7 +1162,7 @@ pub(crate) enum PatchAnswer {
|
|||
Deficit(JurisdictionKey),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct WorldPatch {
|
||||
pub(crate) declarations: Vec<Declaration>,
|
||||
|
|
@ -5429,7 +5429,7 @@ pub(crate) const SUBMIT_PATCH_TOOL: &str = "submit";
|
|||
pub(crate) const PATCH_TOOLS: &[PatchTool] = &[
|
||||
PatchTool {
|
||||
name: "declare_subject",
|
||||
description: "Declare a new subject: a person, an institution, or a population.",
|
||||
description: "Declare a new subject: a person, an institution, or a population. Every subject needs at least one affordance grant, by the bracketed id of a standing affordance or the handle of one declared in this patch; a subject with none cannot act and is refused.",
|
||||
fields: &[
|
||||
field("handle", PatchFieldKind::Handle),
|
||||
field("label", PatchFieldKind::Label),
|
||||
|
|
@ -5977,7 +5977,10 @@ fn field_schema(kind: PatchFieldKind) -> Value {
|
|||
PatchFieldKind::Quantity => tool_schema::bounded_integer(1, u64::MAX),
|
||||
PatchFieldKind::Cost => tool_schema::bounded_integer(1, u64::from(MAX_ROUTE_COST)),
|
||||
PatchFieldKind::Magnitude => tool_schema::bounded_integer(1, u64::from(u32::MAX)),
|
||||
PatchFieldKind::Minutes => tool_schema::bounded_integer(0, u64::MAX),
|
||||
PatchFieldKind::Minutes => tool_schema::described(
|
||||
tool_schema::bounded_integer(0, u64::MAX),
|
||||
"a reading of the world clock in fictional minutes; the prompt states the current reading, and a due must be later than it",
|
||||
),
|
||||
PatchFieldKind::OptionalPeriod => {
|
||||
tool_schema::nullable(tool_schema::bounded_integer(1, u64::from(MAX_ROUTE_COST)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -995,7 +995,7 @@ mod tests {
|
|||
let prepared = seed_prepared(&port, prompt);
|
||||
port.lend_tool_results(
|
||||
&prepared,
|
||||
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
|
||||
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
|
||||
);
|
||||
let output = port.infer(prepared).await.expect("the seed query returns");
|
||||
|
||||
|
|
@ -1036,7 +1036,7 @@ mod tests {
|
|||
let prepared = seed_prepared(&port, "Author the shortfall.");
|
||||
port.lend_tool_results(
|
||||
&prepared,
|
||||
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
|
||||
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
|
||||
);
|
||||
let fault = port
|
||||
.infer(prepared)
|
||||
|
|
@ -1137,7 +1137,7 @@ mod tests {
|
|||
let prepared = seed_prepared(&port, "Author the shortfall.");
|
||||
port.lend_tool_results(
|
||||
&prepared,
|
||||
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
|
||||
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
|
||||
);
|
||||
let fault = port
|
||||
.infer(prepared)
|
||||
|
|
@ -1168,7 +1168,7 @@ mod tests {
|
|||
let prepared = seed_prepared(&port, "Author the shortfall.");
|
||||
port.lend_tool_results(
|
||||
&prepared,
|
||||
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
|
||||
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
|
||||
);
|
||||
let output = port
|
||||
.infer(prepared)
|
||||
|
|
@ -1510,7 +1510,7 @@ mod tests {
|
|||
let prepared = prose_prepared(&port, "Say something true.");
|
||||
port.lend_tool_results(
|
||||
&prepared,
|
||||
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
|
||||
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
|
||||
);
|
||||
let fault = port
|
||||
.infer(prepared)
|
||||
|
|
@ -1528,7 +1528,7 @@ mod tests {
|
|||
.expect("the stale invocation builds");
|
||||
port.lend_tool_results(
|
||||
&stale,
|
||||
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
|
||||
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
|
||||
);
|
||||
assert!(port.infer(stale.clone()).await.is_err());
|
||||
assert!(
|
||||
|
|
@ -1540,7 +1540,7 @@ mod tests {
|
|||
let other = SdkInferencePort::new(ScriptedLink::new(Vec::new()), "ghostlight-other");
|
||||
other.lend_tool_results(
|
||||
&stale,
|
||||
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
|
||||
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
|
||||
);
|
||||
let fault = other
|
||||
.infer(stale)
|
||||
|
|
@ -1562,7 +1562,7 @@ mod tests {
|
|||
let prepared = seed_prepared(&port, "Author the shortfall.");
|
||||
port.lend_tool_results(
|
||||
&prepared,
|
||||
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
|
||||
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
|
||||
);
|
||||
port.infer(prepared).await.expect("the empty query returns");
|
||||
assert!(port.oracles.lock().unwrap().is_empty());
|
||||
|
|
@ -1656,7 +1656,7 @@ mod tests {
|
|||
let prepared = seed_prepared(&port, "Author the shortfall.");
|
||||
port.lend_tool_results(
|
||||
&prepared,
|
||||
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
|
||||
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
|
||||
);
|
||||
let fault = port
|
||||
.infer(prepared)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,15 @@ pub(super) fn bounded_integer(min: u64, max: u64) -> Value {
|
|||
json!({"type": "integer", "minimum": min, "maximum": max})
|
||||
}
|
||||
|
||||
/// The same schema with the text the model reads beside it. Meaning that lives
|
||||
/// only in a prompt paragraph is lost by the time the model fills the field.
|
||||
pub(super) fn described(mut schema: Value, description: &str) -> Value {
|
||||
if let Value::Object(fields) = &mut schema {
|
||||
fields.insert("description".into(), Value::String(description.into()));
|
||||
}
|
||||
schema
|
||||
}
|
||||
|
||||
pub(super) fn name_enum(values: &[&str]) -> Value {
|
||||
json!({"type": "string", "enum": values})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,8 +186,12 @@ tool surface is `PATCH_TOOLS`: one tool per non-genesis `Declaration` and
|
|||
`world/tool_schema.rs` from one schema spelling per type.
|
||||
`world/elaboration.rs` owns the repair loop: a session keyed by a
|
||||
deterministic command id (sha256 over world, jurisdiction, answer digest)
|
||||
submits a draft, persists the resolver's complete mismatch set under that id,
|
||||
and re-prompts with it, bounded by `ELABORATION_ROUND_BUDGET` and the
|
||||
submits a draft, persists the resolver's complete mismatch set under that id
|
||||
as a `Refusal` placed after the rounds that earned it, and continues the same
|
||||
conversation: the refusal is the next user turn, the model's own calls stay in
|
||||
view above it so every mismatch site names something it can see, and the draft
|
||||
is rebuilt from the calls after the refusal. Both authoring lanes share this
|
||||
fold, bounded by `ELABORATION_ROUND_BUDGET` and the
|
||||
`MAX_DRAFT_*` size caps. Evidence a model cites must come from the round's
|
||||
`EvidenceSource` receipts (`filter_evidence`); with `NullEvidenceSource` no
|
||||
canonical fact and no `Admit` can land from the elaborator lane.
|
||||
|
|
@ -282,7 +286,7 @@ knowledge row, and never another subject's snapshot. The re-lowering's
|
|||
provider request is a distinct round (`interpreter_round`) with its own
|
||||
content-addressed request id, so it cannot collide with the first lowering's
|
||||
request. State schema is `ghostlight.world_state.consumer.v2`, commit schema
|
||||
`ghostlight.world_commit.consumer.v2`, controller work `controller_work.v11`,
|
||||
`ghostlight.world_commit.consumer.v2`, controller work `controller_work.v12`,
|
||||
Persona turn receipt `ghostlight.persona_turn_receipt.v3`; earlier stores and
|
||||
earlier rows are refused. Ghostlight owns a conserved narrative ledger;
|
||||
Delvehold owns the economy (`delvehold-forced-ontology-integration.md`).
|
||||
|
|
|
|||
|
|
@ -610,6 +610,23 @@ pass-through; the decoder reports it as missing. The result-subtype mapping
|
|||
(`error_max_turns` is an output; every error subtype a named fault) is
|
||||
tested in the sidecar without a credential.
|
||||
|
||||
Road follow-ups, 2026-09-10, from the first two SDK-backed seeded runs
|
||||
(`notes/local-live-smoke.md`, "Claude SDK route"): the sidecar's schema
|
||||
conversion now carries every field `description` through to the advertised
|
||||
tool schema (it dropped all of them, so the `claude` lane saw bare types);
|
||||
`due` describes itself as a clock reading and both authoring prompts print
|
||||
the clock, because the model wrote twelve commitments due at or before a
|
||||
clock it was never shown; `declare_subject` states the grant rule at the
|
||||
point of use; `apply_tool_call` logs each raw tool call at debug so a refused
|
||||
patch is diagnosed from what the model sent. A kernel refusal now continues
|
||||
the same conversation instead of opening a fresh draft: `Refusal { after_round,
|
||||
mismatches }` rows on both authoring checkpoints (`controller_work.v12`;
|
||||
prior rows refused, not migrated), the refusal rendered as the next user turn
|
||||
after the rounds that earned it, and the draft rebuilt by one `DraftFold`
|
||||
shared by the evaluator and the oracle from the calls after it. The harness
|
||||
takes the connector binding only when `GHOSTLIGHT_CONTROLLER_CONNECTOR` is
|
||||
set, so an all-`claude` run needs no connector.
|
||||
|
||||
## Subtraction budget
|
||||
|
||||
Prefer deletion, collapse, or reuse before adding surfaces. The replacement
|
||||
|
|
|
|||
|
|
@ -194,10 +194,27 @@ 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.
|
||||
### SDK runs, 2026-09-10
|
||||
|
||||
Every lane on `claude-sonnet-5`, Kalsa `Public`, Low Sere, target six, two
|
||||
seed sessions, three ticks. The operator installed the CLI and logged in
|
||||
interactively; the sidecar inherited the login. No connector variables were
|
||||
set.
|
||||
|
||||
| Run | Seed | Ticks | Result |
|
||||
|---|---|---|---|
|
||||
| 1 | both sessions refused: subjects with no grant, twelve commitments due at or before the clock | 3 committed, 2 cells each, Persona spoke | harness failed on the empty seed; the transport itself worked end to end |
|
||||
| 2 | one session, one round, 89 s: six qualified persons, deficit 6 to 0 | 456 s, 395 s, 326 s; 8 singleton cells each; revision 3 to 24 | passed; tick 3 quarantined one cell |
|
||||
|
||||
Rules run 1 imposed are in the implementation plan under step 11 (descriptions
|
||||
carried through the sidecar, `due` described and the clock printed, the grant
|
||||
rule on the subject tool, raw tool calls logged, refusal continues the
|
||||
conversation). Run 2's two findings: three of eight speech captures were cut
|
||||
mid-word ("just t", "a?\" I") because the Interpreter cited byte spans the
|
||||
model counted wrong, and the tick-3 quarantine was a mis-spanned utterance
|
||||
failing the canonical-text rule at invocation, which is a translation gap
|
||||
wearing an infrastructure fault's coat. Ticks are slow because eight singleton
|
||||
cells run in sequence at roughly 50 s per cell on this lane.
|
||||
|
||||
## Interrupted cell
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,19 @@ function keep(schema: z.ZodTypeAny): z.ZodTypeAny {
|
|||
return schema.catch(undefined as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* The text the emitter put beside a field is the model's only account of what
|
||||
* the field means; the first SDK road run lost every description here and the
|
||||
* model filled `due` blind.
|
||||
*/
|
||||
function convert(node: unknown, at: string): z.ZodTypeAny {
|
||||
const converted = convertShape(node, at);
|
||||
return isObject(node) && typeof node.description === "string"
|
||||
? converted.describe(node.description)
|
||||
: converted;
|
||||
}
|
||||
|
||||
function convertShape(node: unknown, at: string): z.ZodTypeAny {
|
||||
if (!isObject(node)) {
|
||||
throw new Error(`${at}: schema node is not an object`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import assert from "node:assert/strict";
|
|||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import { z } from "zod";
|
||||
import { toolInputSchema } from "../src/schema.ts";
|
||||
|
||||
const emitted: { name: string; parameters_json: string }[] = JSON.parse(
|
||||
|
|
@ -24,6 +25,26 @@ test("every emitted tool schema converts", () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("a property's description reaches the advertised schema", () => {
|
||||
const schema = toolInputSchema(
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["due", "kind"],
|
||||
properties: {
|
||||
due: { type: "integer", minimum: 0, maximum: 9, description: "a clock reading" },
|
||||
kind: { type: "string", enum: ["a"], description: "which shape" },
|
||||
},
|
||||
}),
|
||||
"described",
|
||||
);
|
||||
const advertised = z.toJSONSchema(schema, { io: "input" }) as {
|
||||
properties: Record<string, { description?: string }>;
|
||||
};
|
||||
assert.equal(advertised.properties.due?.description, "a clock reading");
|
||||
assert.equal(advertised.properties.kind?.description, "which shape");
|
||||
});
|
||||
|
||||
test("a converted schema carries every property's own type", () => {
|
||||
const schema = toolInputSchema(
|
||||
JSON.stringify({
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue