From cbc215a7d7684b229fec0b2b24602185e740314d Mon Sep 17 00:00:00 2001 From: Raymond Weitekamp <19483938+rawwerks@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:43:11 -0400 Subject: [PATCH] Add Context contract boundary support --- CONTRIBUTING.md | 4 + crates/openprose-lint/CHANGELOG.md | 2 + .../current/context-grounded-summary.prose.md | 15 ++ .../openprose/skills/open-prose/SKILL.md | 10 + .../skills/open-prose/contract-markdown.md | 26 +++ .../openprose/skills/open-prose/reactor.md | 25 ++- .../specs/conformance-capability-schema.json | 5 + .../claude-code-self-declared.json | 5 + .../codex-host-mediated-self-declared.json | 5 + .../hermes-host-mediated-self-declared.json | 5 + .../pi-no-extensions-self-declared.json | 5 + crates/openprose-lint/src/capabilities.rs | 40 ++++ crates/openprose-lint/src/current_lint.rs | 177 ++++++++++++++- .../tests/capabilities_tests.rs | 47 ++++ packages/reactor-cli/README.md | 16 +- .../reactor-cli/src/__tests__/compile.test.ts | 156 ++++++++++++- .../src/__tests__/connectors.test.ts | 36 ++- .../src/__tests__/contract-images.test.ts | 47 +++- .../reactor-cli/src/__tests__/doctor.test.ts | 31 +++ .../src/__tests__/provider-config.test.ts | 76 ++++++- .../reactor-cli/src/__tests__/run.test.ts | 208 ++++++++++++++++++ packages/reactor-cli/src/commands/doctor.ts | 32 ++- packages/reactor-cli/src/commands/trigger.ts | 45 ++-- .../src/compile/contract-images.ts | 20 +- packages/reactor-cli/src/compile/ir-cache.ts | Bin 13132 -> 14075 bytes .../reactor-cli/src/compile/run-compile.ts | 23 ++ packages/reactor-cli/src/config.ts | 38 ++++ packages/reactor-cli/src/run/run-core.ts | 19 +- .../__tests__/contract-loader.test.ts | 15 ++ .../adapters/agent-compile/contract-loader.ts | 30 ++- .../agent-compile/contract-set-input.ts | 3 +- .../__tests__/instructions.test.ts | 46 ++++ .../agent-render/__tests__/tools.test.ts | 22 +- .../src/adapters/agent-render/instructions.ts | 53 ++++- .../src/adapters/agent-render/tools.ts | 21 +- .../src/sdk/__tests__/run-project.test.ts | 54 +++++ packages/reactor/src/sdk/run-project.ts | 4 + skills/open-prose/SKILL.md | 10 + skills/open-prose/changelog.md | 4 + skills/open-prose/contract-markdown.md | 26 +++ skills/open-prose/examples/README.md | 3 + .../examples/context-boundary/README.md | 104 +++++++++ .../src/context-brief.prose.md | 40 ++++ .../src/request-inbox.prose.md | 43 ++++ skills/open-prose/reactor.md | 25 ++- .../intelligent-react-examples-corpus.test.ts | 3 +- 46 files changed, 1544 insertions(+), 80 deletions(-) create mode 100644 crates/openprose-lint/fixtures/current/context-grounded-summary.prose.md create mode 100644 packages/reactor/src/adapters/agent-render/__tests__/instructions.test.ts create mode 100644 skills/open-prose/examples/context-boundary/README.md create mode 100644 skills/open-prose/examples/context-boundary/src/context-brief.prose.md create mode 100644 skills/open-prose/examples/context-boundary/src/request-inbox.prose.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd0a71e5..fc10661b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,10 @@ A strong OpenProse PR should: - Respect the language/framework/harness boundary. Put semantics in the skill and interpreter docs, reusable contracts in `packages/std/`, and deterministic harness behavior in `packages/reactor*/`. +- Preserve the data-flow boundary when changing language features. Runtime data + should flow through `### Requires`/`### Maintains`, gateway ingress, function + parameters, or returns; `### Context` is read-only grounding and should not be + used as a hidden payload or wake mechanism. - Make the library more developer-friendly and agent-friendly at the same time: clearer for humans to review, easier for agents to execute correctly. - Add or identify a retestable mechanism. Use existing tests when they cover diff --git a/crates/openprose-lint/CHANGELOG.md b/crates/openprose-lint/CHANGELOG.md index 9bf5015b..95dbccd4 100644 --- a/crates/openprose-lint/CHANGELOG.md +++ b/crates/openprose-lint/CHANGELOG.md @@ -27,6 +27,8 @@ and this crate follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html - `crates/openprose-lint/scripts/sync-spec-snapshot.sh` to refresh and check the curated packaged OpenProse spec bundle. +- Current Contract Markdown support for `### Context`, including strict lint + recognition and `context-boundary` capability reporting. ## [0.2.0] diff --git a/crates/openprose-lint/fixtures/current/context-grounded-summary.prose.md b/crates/openprose-lint/fixtures/current/context-grounded-summary.prose.md new file mode 100644 index 00000000..e9600c16 --- /dev/null +++ b/crates/openprose-lint/fixtures/current/context-grounded-summary.prose.md @@ -0,0 +1,15 @@ +--- +name: context-grounded-summary +kind: responsibility +version: 0.15.0 +id: 067NC4KG01RG50R40M30E20918 +--- + +### Context + +- `style-guide`: `docs/style-guide.md` at the compiled source revision; read-only + guidance for tone and terminology. + +### Maintains + +- `summary`: concise current summary matching the declared style guidance. diff --git a/crates/openprose-lint/spec-snapshot/openprose/skills/open-prose/SKILL.md b/crates/openprose-lint/spec-snapshot/openprose/skills/open-prose/SKILL.md index 8cbf24e9..7414bdc7 100644 --- a/crates/openprose-lint/spec-snapshot/openprose/skills/open-prose/SKILL.md +++ b/crates/openprose-lint/spec-snapshot/openprose/skills/open-prose/SKILL.md @@ -219,6 +219,10 @@ Contract Markdown uses Markdown headers as the canonical human-facing syntax: - `topic`: the question to investigate +### Context + +- `style-guide`: read-only guidance to consult during the render + ### Maintains - `report`: concise answer with sources @@ -251,6 +255,12 @@ Header hierarchy: - `##` starts an inline contract in multi-contract files. - `###` starts a section inside the current responsibility or function. +Authoring rule for `### Context`: use it for bounded, read-only grounding such +as policy notes, style guides, source paths, or prior decisions. Do not use it +to carry runtime data. If data should wake a node, author a gateway that +`### Maintains` the incoming truth and a downstream responsibility that +`### Requires` the relevant facet. + ## File Locations All OpenProse skill files are colocated with this `SKILL.md`. Do not search the diff --git a/crates/openprose-lint/spec-snapshot/openprose/skills/open-prose/contract-markdown.md b/crates/openprose-lint/spec-snapshot/openprose/skills/open-prose/contract-markdown.md index 20c17784..8bf32dda 100644 --- a/crates/openprose-lint/spec-snapshot/openprose/skills/open-prose/contract-markdown.md +++ b/crates/openprose-lint/spec-snapshot/openprose/skills/open-prose/contract-markdown.md @@ -208,6 +208,7 @@ Forme and the Prose VM recognize these `###` sections case-insensitively: | `### Description` | all | Human summary. Preserved for readers; not used as a contract | | `### Goal` | responsibility, gateway | The render's one-sentence standing intent | | `### Requires` | responsibility, pattern slots | Subscription contracts naming facet-level needs; a `####` sub-heading is a facet-need. Forme's match target (`Requires. ↔ Maintains.`) | +| `### Context` | responsibility, function | Declared read-only context the render may consult. It is preserved for compile/run evidence and contract identity, but it does not create Forme edges or satisfy `### Requires`, `### Maintains`, or `### Returns` | | `### Maintains` | responsibility, gateway | The world-model **schema** — type, canonicalization spec, facets (a `####` sub-heading is a facet, the named-parts rule), and postconditions (see [Maintains](#maintains)) | | `### Parameters` | function | Inputs the caller passes at call time | | `### Returns` | function | The value the function returns | @@ -379,6 +380,31 @@ with the human); the resolved producer is Forme's choice (mechanism). Load `responsibility-runtime.md` and `concepts/responsibility.md` for the compile/run reconciler semantics. +## Context + +`### Context` declares bounded, read-only context the render may consult while +doing its work: style guides, policy notes, domain glossaries, source paths, +prior decisions, or operator guidance. It is part of the source contract, so +compilers and harnesses preserve it in the contract image they hand to the +render and include it when deriving contract identity. + +`### Context` is not dependency injection. It does not subscribe to another +responsibility, does not wake a node, and does not publish truth. Use +`### Requires` for subscribed inputs, `### Maintains` for responsibility output +truth, and `### Returns` for function results. A source with only `### Context` +and no `### Maintains`/`### Returns` is still missing its output contract. + +For Reactor-triggered work, incoming JSON or webhook payloads belong in a +`kind: gateway` that `### Maintains` normalized ingress truth. Downstream +responsibilities then read that truth through `### Requires` and the compiled +edge Forme draws. `### Context` may explain how to interpret the request, name a +source path, or bound a policy decision, but the payload itself is not ambient +context and should not be treated as if it magically appeared in the render. + +Harnesses that claim Context support must avoid silently adding undeclared +ambient parent-session context to a child render. When a child activation is +used, the launch artifact should make the declared context boundary inspectable. + ## Maintains `### Maintains` declares the **shape** of the world-model — the schema, not the diff --git a/crates/openprose-lint/spec-snapshot/openprose/skills/open-prose/reactor.md b/crates/openprose-lint/spec-snapshot/openprose/skills/open-prose/reactor.md index 3bd38c03..7c688975 100644 --- a/crates/openprose-lint/spec-snapshot/openprose/skills/open-prose/reactor.md +++ b/crates/openprose-lint/spec-snapshot/openprose/skills/open-prose/reactor.md @@ -101,6 +101,11 @@ devtools `--describe`) the agent may always run directly to validate its output. helpers for expensive sub-steps. Apply the anti-pattern checklist: no "loop until done", no volatile fields in `### Maintains`, declare `valid_until` in `### Continuity`, facet the truth so an unrelated change wakes nobody. + If a downstream node needs a trigger body, webhook body, file event, or any + other runtime payload, model that payload as gateway-maintained truth and add + a `### Requires` facet on the downstream responsibility. Do not put runtime + payloads in `### Context`; Context is read-only grounding and does not create + the Forme edge that wakes the subscriber. 5. **Author `reactor.yml`.** State dir, model block, sandbox, and the gateway connector (see *Configuration* below). **Ask the user for provider/model** here @@ -147,8 +152,8 @@ model: compile_model: google/gemini-3.5-flash temperature: 0 # optional — delete the line to send no temperature max_turns: 200 - # reasoning_effort: none # reasoning models (gpt-5.x, o-series) reject an - # explicit temperature unless effort is none + # reasoning_effort: none # reasoning-class models (GPT-5/o-series/Claude 4) + # often reject explicit temperature unless effort is none sandbox: mode: none # none (default, bounded shell) | docker (network-disabled container) @@ -168,6 +173,12 @@ reactors: [] # optional: a multi-reactor host Global flags `--state-dir`, `--project`, `--json`, `--offline` override the file on every command. +Run `reactor doctor --json` before live `compile`/`run`. Doctor is keyless, and +reports model compatibility warnings for known provider failure classes. For +GPT-5/o-series/Claude 4-class models through OpenRouter-compatible endpoints, +prefer deleting `temperature:` unless you deliberately set `reasoning_effort: +none`. + **Provider/model — ask, don't assume.** The scaffold default is OpenRouter + `google/gemini-3.5-flash`. When generating `reactor.yml`, ask the user which provider/model to use; suggest **OpenRouter** as the safe default and note that @@ -210,6 +221,16 @@ note (never crashes). `reactor doctor` reports Docker availability under | `GET /cost` | Cost rollup by `surprise_cause` | | `POST /trigger/` | Wake `` with an optional JSON body as an external arrival; returns the disposition | +When using `POST /trigger/` or +`reactor trigger --data ...`, the payload is staged into that gateway's +ingress truth. A downstream subscriber sees it only if the gateway +`### Maintains` a facet and the subscriber `### Requires` that facet. A bare +trigger of a non-gateway node can wake the node, but it is not the payload +delivery pattern. Verify the result with `reactor topology`, +`reactor inspect `, `reactor trace `, and +`reactor receipts verify`; a successful payload delivery should show the ingress +receipt, the gateway receipt, and the subscriber receipt. + > **⚠ No auth in v1.** `POST /trigger/` is unauthenticated and can cause > model spend. The default `127.0.0.1` bind is loopback-only; expose it > (`--host 0.0.0.0`) only behind a proxy that adds auth + rate-limiting. Treat diff --git a/crates/openprose-lint/specs/conformance-capability-schema.json b/crates/openprose-lint/specs/conformance-capability-schema.json index e65faf21..3dacc88f 100644 --- a/crates/openprose-lint/specs/conformance-capability-schema.json +++ b/crates/openprose-lint/specs/conformance-capability-schema.json @@ -123,6 +123,11 @@ "description": "Resume interrupted runs from artifacts and state.md", "depends_on": ["file-io", "state-markers"] }, + "context-boundary": { + "layer": "protocol", + "description": "Preserve declared Context sections, avoid treating undeclared ambient context as input, and record bounded launch-context artifacts when child activations are used", + "depends_on": ["subagents", "state-markers"] + }, "secret-hygiene": { "layer": "policy", "description": "Verify environment presence without leaking raw secret values into artifacts or logs", diff --git a/crates/openprose-lint/specs/runtime-subjects/claude-code-self-declared.json b/crates/openprose-lint/specs/runtime-subjects/claude-code-self-declared.json index 4a8188ce..785cfbbf 100644 --- a/crates/openprose-lint/specs/runtime-subjects/claude-code-self-declared.json +++ b/crates/openprose-lint/specs/runtime-subjects/claude-code-self-declared.json @@ -109,6 +109,11 @@ "mode": "incidental", "verification": "unverified", "notes": "Claude Code has session resume (--continue, --resume flags) which restores conversation context. However, OpenProse resume requires resuming from structured artifacts and state.md markers, which is a different protocol. The substrate supports continuation; the OpenProse-specific semantics are incidental." + }, + "context-boundary": { + "mode": "incidental", + "verification": "unverified", + "notes": "Claude Code can use isolated subagents and write files, but raw CLI usage does not deterministically preserve declared OpenProse Context sections, exclude undeclared ambient context, or write bounded launch-context artifacts without an OpenProse adapter." } }, "profile_assessment": { diff --git a/crates/openprose-lint/specs/runtime-subjects/codex-host-mediated-self-declared.json b/crates/openprose-lint/specs/runtime-subjects/codex-host-mediated-self-declared.json index d9a0b92b..0661ef53 100644 --- a/crates/openprose-lint/specs/runtime-subjects/codex-host-mediated-self-declared.json +++ b/crates/openprose-lint/specs/runtime-subjects/codex-host-mediated-self-declared.json @@ -106,6 +106,11 @@ "mode": "unsupported", "verification": "self-declared", "notes": "OpenProse resume from structured artifacts is not implemented in the current Codex host-mediated runner." + }, + "context-boundary": { + "mode": "unsupported", + "verification": "self-declared", + "notes": "The current host-mediated runner does not preserve declared Context sections, exclude undeclared ambient context, or write bounded launch-context artifacts for child activations." } }, "profile_assessment": { diff --git a/crates/openprose-lint/specs/runtime-subjects/hermes-host-mediated-self-declared.json b/crates/openprose-lint/specs/runtime-subjects/hermes-host-mediated-self-declared.json index 0c8cfec8..d0f7a043 100644 --- a/crates/openprose-lint/specs/runtime-subjects/hermes-host-mediated-self-declared.json +++ b/crates/openprose-lint/specs/runtime-subjects/hermes-host-mediated-self-declared.json @@ -106,6 +106,11 @@ "mode": "unsupported", "verification": "self-declared", "notes": "OpenProse resume from structured artifacts is not implemented in the current Hermes host-mediated runner." + }, + "context-boundary": { + "mode": "unsupported", + "verification": "self-declared", + "notes": "The current host-mediated runner does not preserve declared Context sections, exclude undeclared ambient context, or write bounded launch-context artifacts for child activations." } }, "profile_assessment": { diff --git a/crates/openprose-lint/specs/runtime-subjects/pi-no-extensions-self-declared.json b/crates/openprose-lint/specs/runtime-subjects/pi-no-extensions-self-declared.json index e601904f..99a882a9 100644 --- a/crates/openprose-lint/specs/runtime-subjects/pi-no-extensions-self-declared.json +++ b/crates/openprose-lint/specs/runtime-subjects/pi-no-extensions-self-declared.json @@ -89,6 +89,11 @@ "resume": { "mode": "unsupported", "verification": "self-declared" + }, + "context-boundary": { + "mode": "unsupported", + "verification": "self-declared", + "notes": "Bare pi has no OpenProse adapter that preserves declared Context sections or writes bounded launch-context artifacts." } }, "profile_assessment": { diff --git a/crates/openprose-lint/src/capabilities.rs b/crates/openprose-lint/src/capabilities.rs index 8ed4041b..b1a3aab5 100644 --- a/crates/openprose-lint/src/capabilities.rs +++ b/crates/openprose-lint/src/capabilities.rs @@ -48,6 +48,8 @@ pub struct CapabilityRequirements { #[serde(rename = "test-evaluation")] pub test_evaluation: bool, pub resume: bool, + #[serde(rename = "context-boundary")] + pub context_boundary: bool, #[serde(rename = "secret-hygiene")] pub secret_hygiene: bool, } @@ -206,6 +208,11 @@ fn build_report( let trimmed = line.trim_start().to_ascii_lowercase(); trimmed.starts_with("expects:") || trimmed.starts_with("expects-not:") })); + let context_boundary = !sections.context.is_empty() + || source.lines().any(|line| { + let trimmed = line.trim_start().to_ascii_lowercase(); + trimmed.starts_with("context:") + }); let delegation = frontmatter.all_keys.contains_key("delegates") || source.contains("\nDelegate:") || source.starts_with("Delegate:") @@ -238,6 +245,7 @@ fn build_report( test_execution, test_evaluation, resume, + context_boundary, secret_hygiene: environment.required, }; let provenance = requirement_provenance(schema, &requires)?; @@ -404,6 +412,7 @@ fn validate_schema_capabilities(schema: &CapabilitySchema) -> Result<()> { "test-execution", "test-evaluation", "resume", + "context-boundary", "secret-hygiene", ]; @@ -509,6 +518,9 @@ fn direct_required_capabilities(requires: &CapabilityRequirements) -> BTreeSet, + pub(crate) context: Vec, pub(crate) ensures: Vec, pub(crate) maintains: Vec, pub(crate) parameters: Vec, @@ -124,6 +125,7 @@ pub(crate) const KNOWN_CONTRACT_SECTIONS: &[&str] = &[ "description", "goal", "requires", + "context", "maintains", "parameters", "returns", @@ -1014,6 +1016,7 @@ pub(crate) fn parse_markdown_body( fn push_contract_item(sections: &mut ContractSections, section: &str, item: ContractItem) { match section { "requires" => sections.requires.push(item), + "context" => sections.context.push(item), "ensures" => sections.ensures.push(item), "maintains" => sections.maintains.push(item), "parameters" => sections.parameters.push(item), @@ -1050,6 +1053,19 @@ fn validate_contracts( } } + for item in §ions.context { + if item.text.trim().is_empty() { + diagnostics.push(Diagnostic::new( + path, + "MDW010", + Severity::Warning, + "Empty context clause", + item.line, + 1, + )); + } + } + for item in §ions.ensures { if item.text.trim().is_empty() { diagnostics.push(Diagnostic::new( @@ -1772,6 +1788,10 @@ Keep the account risk model current. - `signals`: account signals +### Context + +- `style-guide`: read-only style guidance for the render + ### Maintains - `risk`: current account risk @@ -1885,7 +1905,7 @@ call worker } #[test] - fn discovery_reports_potential_undocumented_contract_sections() { + fn discovery_treats_context_as_documented_contract_section() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("context.prose.md"); fs::write( @@ -1911,20 +1931,159 @@ id: 067NC4KG01RG50R40M30E20918 let discovery = discover_spec_gaps(&[path]).unwrap(); assert!( - discovery.undocumented_sections.contains_key("context"), - "discovery should surface recurring section candidates: {discovery:#?}" + !discovery.undocumented_sections.contains_key("context"), + "Context is an official section and should not be reported as drift: {discovery:#?}" ); assert!( - discovery.doc_heading_patterns.contains_key("Context"), - "discovery should print documentation heading patterns: {discovery:#?}" + !discovery.doc_heading_patterns.contains_key("Context"), + "Context is an official section, not a documentation heading pattern: {discovery:#?}" ); let rendered = discovery.to_string(); assert!( - rendered.contains("Potential undocumented contract sections") - && rendered.contains("### context") - && rendered.contains("Documentation heading patterns"), - "rendered discovery should include section drift: {rendered}" + !rendered.contains("### context"), + "rendered discovery should not include known Context as drift: {rendered}" + ); + } + + #[test] + fn context_after_maintains_preserves_facets_and_later_sections() { + let source = "\ +--- +name: context-after-maintains +kind: responsibility +version: 0.15.0 +id: 067NC4KG01RG50R40M30E20918 +--- + +### Maintains + +- `answer`: answer grounded in declared inputs. + +#### evidence + +Material evidence supporting the answer. + +### Context + +- `style-guide`: `docs/style-guide.md` at the compiled source revision. + +### Strategies + +- quote the context only when it directly clarifies the answer. +"; + let result = current_lint_source_with_profile( + Path::new("context-after-maintains.prose.md"), + source, + LintProfile::Strict, + ); + assert!( + !result.diagnostics.iter().any(|d| d.code == "MDW014"), + "context after maintains should not hide maintains/facets: {:?}", + result.diagnostics + ); + assert!( + result + .diagnostics + .iter() + .all(|d| d.code != "MDW020" && d.code != "MDW030"), + "context and facet headings should not become components: {:?}", + result.diagnostics + ); + + let mut parse_diags = Vec::new(); + let (frontmatter, body_start) = parse_frontmatter( + Path::new("context-after-maintains.prose.md"), + source, + &mut parse_diags, + ); + let body = source + .lines() + .skip(body_start) + .collect::>() + .join("\n"); + let (_headings, sections) = parse_markdown_body( + Path::new("context-after-maintains.prose.md"), + &body, + body_start, + &frontmatter, + &mut parse_diags, + ); + assert_eq!( + sections.context.len(), + 1, + "post-Maintains Context must be parsed as a Context section" + ); + assert!( + sections.context[0].text.contains("style-guide"), + "Context item should stay in sections.context: {:?}", + sections.context + ); + assert_eq!( + sections.strategies.len(), + 1, + "later Strategies must remain its own section" + ); + assert!( + !sections.maintains.iter().any(|item| { + item.text.contains("style-guide") || item.text.contains("quote the context") + }), + "Context/Strategies text must not leak into Maintains: {:?}", + sections.maintains + ); + } + + #[test] + fn context_does_not_satisfy_required_output_contracts() { + let responsibility = "\ +--- +name: context-only-responsibility +kind: responsibility +version: 0.15.0 +id: 067NC4KG01RG50R40M30E20918 +--- + +### Context + +- `answer`: tempting but read-only orientation, not maintained truth. +"; + let responsibility_result = current_lint_source_with_profile( + Path::new("context-only-responsibility.prose.md"), + responsibility, + LintProfile::Strict, + ); + assert!( + responsibility_result + .diagnostics + .iter() + .any(|d| d.code == "MDW014" && d.message.contains("maintains")), + "Context must not satisfy a responsibility's Maintains contract: {:?}", + responsibility_result.diagnostics + ); + + let function = "\ +--- +name: context-only-function +kind: function +version: 0.15.0 +--- + +### Context + +- `summary`: tempting but read-only orientation, not a return value. +"; + let function_result = current_lint_source_with_profile( + Path::new("context-only-function.prose.md"), + function, + LintProfile::Strict, + ); + assert!( + function_result + .diagnostics + .iter() + .any(|d| d.code == "MDW014" && d.message.contains("returns")), + "Context must not satisfy a function's Returns contract: {:?}", + function_result.diagnostics ); } diff --git a/crates/openprose-lint/tests/capabilities_tests.rs b/crates/openprose-lint/tests/capabilities_tests.rs index 275e112a..37bed75a 100644 --- a/crates/openprose-lint/tests/capabilities_tests.rs +++ b/crates/openprose-lint/tests/capabilities_tests.rs @@ -56,6 +56,21 @@ fn capabilities_reports_test_requirements() { assert_eq!(json["requires"]["ask-user"], false); } +#[test] +fn capabilities_reports_context_boundary_for_context_sections() { + let output = run(&[ + "capabilities", + "fixtures/current/context-grounded-summary.prose.md", + ]); + let json = parse_json(&output); + + assert_eq!(json["program"], "context-grounded-summary"); + assert_eq!(json["requires"]["context-boundary"], true); + assert_eq!(json["requires"]["ask-user"], false); + assert_eq!(json["implied_substrate"]["subagents"], true); + assert_eq!(json["implied_substrate"]["file-io"], true); +} + #[test] fn capabilities_accepts_program_directory_targets() { let dir = Path::new("../../skills/open-prose/examples/competitor-activity/src"); @@ -112,3 +127,35 @@ fn capabilities_runtime_check_accepts_claude_manifest_shape() { .any(|line| line.as_str().unwrap().contains("incidental")) ); } + +#[test] +fn capabilities_runtime_check_blocks_context_when_runtime_lacks_boundary_support() { + let output = run(&[ + "capabilities", + "--runtime-manifest", + "specs/runtime-subjects/codex-host-mediated-self-declared.json", + "fixtures/current/context-grounded-summary.prose.md", + ]); + assert_eq!(output.status.code(), Some(1), "status: {:?}", output.status); + let json = parse_json_any_status(&output); + + assert_eq!( + json["runtime_check"]["subject"], + "Codex CLI via openprose-lint host-mediated OpenProse runner" + ); + assert_eq!(json["runtime_check"]["compatible"], false); + assert!( + json["runtime_check"]["blocking"] + .as_array() + .unwrap() + .iter() + .any(|line| line.as_str().unwrap().contains("context-boundary")), + "expected context-boundary blocking diagnostic: {json:#?}" + ); + let blocking = json["runtime_check"]["blocking"].as_array().unwrap(); + assert_eq!( + blocking.len(), + 1, + "context fixture should isolate the context-boundary blocker: {json:#?}" + ); +} diff --git a/packages/reactor-cli/README.md b/packages/reactor-cli/README.md index e9ab9e5f..21e1f2ff 100644 --- a/packages/reactor-cli/README.md +++ b/packages/reactor-cli/README.md @@ -140,7 +140,7 @@ single reactor): | `GET /health` | Liveness — `200` with `{ "status": "ok" }` once the host is up. | | `GET /status` | Standing compile cost beside live run cost + per-node dispositions (the `status` command's JSON). | | `GET /cost` | The cost rollup by `surprise_cause` (the `receipts cost` JSON). | -| `POST /trigger/` | Wake `` with an optional JSON body as an external arrival; returns the resulting disposition. | +| `POST /trigger/` | Wake ``; an optional JSON body is delivered only when `` is a configured gateway. | It drains in-flight work on `SIGINT`/`SIGTERM` before exiting. @@ -201,8 +201,8 @@ model: compile_model: google/gemini-3.5-flash temperature: 0 # optional — delete the line to send no temperature max_turns: 200 - # reasoning_effort: none # reasoning models (gpt-5.x, o-series) reject an - # explicit temperature unless effort is none + # reasoning_effort: none # reasoning-class models (GPT-5/o-series/Claude 4) + # often reject explicit temperature unless effort is none sandbox: mode: none # none (default) | docker @@ -219,6 +219,12 @@ gateways: # external-driven entry points reactors: [] # optional: a multi-reactor host (see below) ``` +Run `reactor doctor --json` before live `compile`/`run`. Doctor is keyless, and +reports model compatibility warnings for known provider failure classes. For +GPT-5/o-series/Claude 4-class models through OpenRouter-compatible endpoints, +prefer deleting `temperature:` unless you deliberately set `reasoning_effort: +none`. + Global flags `--state-dir`, `--project`, `--json`, `--offline` override the file on every command. @@ -272,11 +278,11 @@ Run `reactor --help` for the full options of any command. | Command | Live? | What it does | | --- | --- | --- | | `reactor init [dir]` | offline | Scaffold a minimal project (gateway + responsibility + `reactor.yml` + `.gitignore`). Refuses a non-empty target dir without `--force`. | -| `reactor doctor [--live]` | offline (`--live` probes) | Report node/SDK/key/deps/offline/sandbox/state-dir/IR health. `--live` runs one smoke render. | +| `reactor doctor [--live]` | offline (`--live` probes) | Report node/SDK/key/deps/offline/sandbox/state-dir/IR health plus keyless model-config warnings. `--live` runs one smoke render. | | `reactor compile [--force] [--check]` | live (cache hit/`--check` offline) | Run compile sessions → IR cache. `--check` exits non-zero when stale. | | `reactor run` | live | Ensure IR fresh, boot, drain to quiescence, report + exit. | | `reactor serve [--http ] [--concurrency ] [--poll-interval ]` | live | Boot the durable host + continuity loop + HTTP surface. | -| `reactor trigger [--data |@file]` | live | Trigger a node with an external wake (one-shot mount, or POST to a daemon). With `--data`, the payload is STAGED into the node's ingress so it actually reaches the render (not just the report). | +| `reactor trigger [--data |@file]` | live | Trigger a node with an external wake (one-shot mount, or POST to a daemon). With `--data`, `` must be an external gateway; the payload is STAGED into gateway ingress so downstream responsibilities receive it through `### Requires`/`### Maintains`. | | `reactor status` | offline | Standing compile cost beside live run cost + dispositions. | | `reactor topology` | offline | Print the compiled DAG: nodes (+ wake source) and resolved edges. | | `reactor inspect [--strict]` | offline | A node's topology position, fingerprints, last receipt, chain. | diff --git a/packages/reactor-cli/src/__tests__/compile.test.ts b/packages/reactor-cli/src/__tests__/compile.test.ts index b76ebe22..acf956ef 100644 --- a/packages/reactor-cli/src/__tests__/compile.test.ts +++ b/packages/reactor-cli/src/__tests__/compile.test.ts @@ -27,6 +27,7 @@ import { ATOMIC_FACET } from '@openprose/reactor'; import { runCompileCommand } from '../commands/compile'; import { manifestPath, loadIR, readTopologyShape, compileDir } from '../compile/ir-cache'; import { firstErrorLine } from '../compile/run-compile'; +import { contractViewFor } from '../run/run-core'; import { fakeStructuredProvider } from './fake-provider'; import { fakeTelemetry } from './fake-telemetry'; import { TelemetryEvent, NOOP_TELEMETRY, type Telemetry } from '../telemetry'; @@ -137,14 +138,24 @@ function capture(): { write: (l: string) => void; lines: string[] } { } async function compileOnce(stateDir: string, force = false, telemetry: Telemetry = NOOP_TELEMETRY) { + return compileProjectOnce(FIXTURE_DIR, stateDir, force, telemetry); +} + +async function compileProjectOnce( + projectDir: string, + stateDir: string, + force = false, + telemetry: Telemetry = NOOP_TELEMETRY, + providers: ReturnType | ReturnType = testProviders(), +) { const out = capture(); const code = await runCompileCommand( { - projectDir: FIXTURE_DIR, + projectDir, stateDir, json: true, force, - testProviders: testProviders(), + testProviders: providers, testSkill: 'TEST SKILL', }, out.write, @@ -154,6 +165,88 @@ async function compileOnce(stateDir: string, force = false, telemetry: Telemetry return { code, report }; } +function contextProject(): string { + const dir = mkdtempSync(join(tmpdir(), 'reactor-cli-context-project-')); + const monitor = readFileSync(join(FIXTURE_DIR, 'competitor-monitor.prose.md'), 'utf8'); + const brief = readFileSync(join(FIXTURE_DIR, 'weekly-brief.prose.md'), 'utf8').replace( + '### Maintains', + '### Context\n\n- `style-guide`: read-only briefing tone guidance.\n\n### Maintains', + ); + writeFileSync(join(dir, 'competitor-monitor.prose.md'), monitor, 'utf8'); + writeFileSync(join(dir, 'weekly-brief.prose.md'), brief, 'utf8'); + return dir; +} + +const REQUEST_INBOX = 'request-inbox'; +const CONTEXT_BRIEF = 'context-brief'; +const CONTEXT_EXAMPLE_SRC = join( + __dirname, + '..', + '..', + '..', + '..', + 'skills/open-prose/examples/context-boundary/src', +); +const REQUEST_CONTEXT = [ + '- Source of truth: read the triggered request payload from `request-inbox` on facet `request`.', + '- Treat this section as read-only grounding; do not invent a request id, source revision, or user goal.', + '- Context can explain how to interpret the request, but it does not satisfy the `request` requirement.', +].join('\n'); + +const CONTEXT_FORME_OUTPUT = JSON.stringify({ + nodes: [ + { + id: REQUEST_INBOX, + kind: 'gateway', + wake_source: 'external', + requires: [], + maintains: ['request'], + }, + { + id: CONTEXT_BRIEF, + kind: 'responsibility', + wake_source: 'input', + requires: [{ facet: 'request' }], + maintains: ['brief'], + }, + ], + matches: [ + { subscriber: CONTEXT_BRIEF, requirement: 'request', producer: REQUEST_INBOX, facet: 'request' }, + ], +}); + +const REQUEST_CANON_OUTPUT = JSON.stringify({ + fields: [{ path: 'request', material: true }], + default_material: true, + facets: [{ facet: 'request', paths: ['request'] }], +}); + +const CONTEXT_BRIEF_CANON_OUTPUT = JSON.stringify({ + fields: [{ path: 'brief', material: true }], + default_material: true, + facets: [], +}); + +const EMPTY_POSTCONDITION_OUTPUT = JSON.stringify({ postconditions: [] }); + +function explicitContextProject(): string { + return CONTEXT_EXAMPLE_SRC; +} + +function explicitContextProviders() { + return { + forme: fakeStructuredProvider(CONTEXT_FORME_OUTPUT), + canonicalizer: { + [REQUEST_INBOX]: fakeStructuredProvider(REQUEST_CANON_OUTPUT), + [CONTEXT_BRIEF]: fakeStructuredProvider(CONTEXT_BRIEF_CANON_OUTPUT), + }, + postcondition: { + [REQUEST_INBOX]: fakeStructuredProvider(EMPTY_POSTCONDITION_OUTPUT), + [CONTEXT_BRIEF]: fakeStructuredProvider(EMPTY_POSTCONDITION_OUTPUT), + }, + }; +} + describe('reactor compile (offline gate)', () => { it('compiles the smallest-project fixture to a content-addressed IR cache', async () => { const stateDir = freshStateDir(); @@ -185,6 +278,65 @@ describe('reactor compile (offline gate)', () => { } }); + it('persists Context in the run-phase contract view cache', async () => { + const projectDir = contextProject(); + const stateDir = freshStateDir(); + try { + const { code } = await compileProjectOnce(projectDir, stateDir); + assert.equal(code, 0); + + const ir = loadIR(stateDir); + assert.equal( + ir.contractViews[BRIEF]?.context, + '- `style-guide`: read-only briefing tone guidance.', + ); + assert.equal( + contractViewFor(ir, BRIEF).context, + '- `style-guide`: read-only briefing tone guidance.', + ); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it('preserves an explicit Context program through topology and run contract views', async () => { + const projectDir = explicitContextProject(); + const stateDir = freshStateDir(); + try { + const { code } = await compileProjectOnce( + projectDir, + stateDir, + false, + NOOP_TELEMETRY, + explicitContextProviders(), + ); + assert.equal(code, 0); + + const ir = loadIR(stateDir); + assert.deepEqual(ir.topology.topology.edges, [ + { subscriber: CONTEXT_BRIEF, producer: REQUEST_INBOX, facet: 'request' }, + ]); + assert.equal( + ir.topology.topology.nodes.find((n) => n.node === REQUEST_INBOX)?.wake_source, + 'external', + ); + assert.equal( + ir.topology.topology.nodes.find((n) => n.node === CONTEXT_BRIEF)?.wake_source, + 'input', + ); + + const persisted = ir.contractViews[CONTEXT_BRIEF]; + assert.equal(persisted?.context, REQUEST_CONTEXT); + assert.match(String(persisted?.execution), /wm_read_upstream/); + + const runView = contractViewFor(ir, CONTEXT_BRIEF); + assert.equal(runView.context, REQUEST_CONTEXT); + assert.match(String(runView.execution), /request-inbox/); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } + }); + it('is content-addressed: two compiles produce identical fingerprints + manifest', async () => { const a = freshStateDir(); const b = freshStateDir(); diff --git a/packages/reactor-cli/src/__tests__/connectors.test.ts b/packages/reactor-cli/src/__tests__/connectors.test.ts index e3d8e0e4..13adfd46 100644 --- a/packages/reactor-cli/src/__tests__/connectors.test.ts +++ b/packages/reactor-cli/src/__tests__/connectors.test.ts @@ -528,7 +528,7 @@ describe('reactor connectors / gateway ingress (offline gate)', () => { } }); - it('the ONE-SHOT `reactor trigger --data` mount folds the payload into the node (B3)', async () => { + it('the ONE-SHOT `reactor trigger --data` mount folds the payload into the gateway (B3)', async () => { const stateDir = freshState(); try { const lines: string[] = []; @@ -563,4 +563,38 @@ describe('reactor connectors / gateway ingress (offline gate)', () => { rmSync(stateDir, { recursive: true, force: true }); } }); + + it('the ONE-SHOT `reactor trigger --data` path fails closed (B3)', async () => { + const stateDir = freshState(); + try { + const lines: string[] = []; + const code = await runTriggerCommand( + { + node: DIGEST, + data: JSON.stringify({ id: 'one-shot-bad', body: 'must not be ambient' }), + projectDir: FIXTURE_DIR, + stateDir, + json: true, + offline: true, + testAdapters: { + clock: createSystemClockAdapter(), + storage: createMemoryStorageAdapter(), + worldModel: new FileSystemWorldModelStore({ directory: join(stateDir, 'world-models') }), + }, + testRender: { buildRender: buildFakeRender as never }, + testCompileOptions: testCompileOptions() as never, + }, + (l) => lines.push(l), + ); + assert.equal(code, 1); + const report = JSON.parse(lines.join('\n')) as { status: string; message: string }; + assert.equal(report.status, 'error'); + assert.match(report.message, /--data can only be delivered to an external gateway node/); + + const store = new FileSystemWorldModelStore({ directory: join(stateDir, 'world-models') }); + assert.equal(ticketCount(store as never), 0, 'non-gateway --data did not stage ingress truth'); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/reactor-cli/src/__tests__/contract-images.test.ts b/packages/reactor-cli/src/__tests__/contract-images.test.ts index a15014fb..6437fb10 100644 --- a/packages/reactor-cli/src/__tests__/contract-images.test.ts +++ b/packages/reactor-cli/src/__tests__/contract-images.test.ts @@ -4,7 +4,8 @@ import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { enumerateContractFiles } from '../compile/contract-images'; +import { enumerateContractFiles, sliceContract } from '../compile/contract-images'; +import { contractSetFingerprint } from '../compile/ir-cache'; // Regression for the `reactor doctor` hang found by Wave-0 stranger validation: // the contract walk used `stat` (follows symlinks) with no cycle guard, so a walk @@ -29,3 +30,47 @@ test('enumerateContractFiles terminates on a symlink cycle and finds only real c rmSync(root, { recursive: true, force: true }); } }); + +test('contract images preserve Context and Context edits move the set fingerprint', () => { + const source = `--- +name: context-grounded-summary +kind: responsibility +--- + +### Requires +- source signal + +### Context +- style guide A + +### Maintains +- summary +`; + const changed = source.replace('style guide A', 'style guide B'); + + const a = sliceContract(source, '/x/context-grounded-summary.prose.md'); + const b = sliceContract(changed, '/x/context-grounded-summary.prose.md'); + + assert.equal(a.context, '- style guide A'); + assert.equal(b.context, '- style guide B'); + assert.notEqual(contractSetFingerprint([a]), contractSetFingerprint([b])); +}); + +test('contract images read recognized Contract Markdown sections case-insensitively', () => { + const source = `--- +name: mixed-case +kind: responsibility +--- + +### context +- lowercase context + +### MAINTAINS +- uppercase maintains +`; + + const image = sliceContract(source, '/x/mixed-case.prose.md'); + + assert.equal(image.context, '- lowercase context'); + assert.equal(image.maintains, '- uppercase maintains'); +}); diff --git a/packages/reactor-cli/src/__tests__/doctor.test.ts b/packages/reactor-cli/src/__tests__/doctor.test.ts index 1b0d7e8e..75ac8cb7 100644 --- a/packages/reactor-cli/src/__tests__/doctor.test.ts +++ b/packages/reactor-cli/src/__tests__/doctor.test.ts @@ -196,6 +196,37 @@ describe('doctor', () => { rmSync(dir, { recursive: true, force: true }); } }); + + it('surfaces keyless model compatibility warnings in JSON and human output', async () => { + const dir = mkdtempSync(join(tmpdir(), 'reactor-cli-doctor-')); + try { + writeFileSync( + join(dir, 'reactor.yml'), + [ + 'model:', + ' provider: openrouter', + ' render_model: openai/gpt-5.4', + ' compile_model: openai/gpt-5.4', + ' temperature: 0', + ].join('\n'), + 'utf8', + ); + const report = await collectDoctorReport({ projectDir: dir }); + assert.equal(report.model.renderModel, 'openai/gpt-5.4'); + assert.equal(report.model.compileModel, 'openai/gpt-5.4'); + assert.equal(report.model.compatibilityWarnings.length, 1); + assert.match(report.model.compatibilityWarnings[0]!, /temperature/); + assert.equal(report.healthyForOffline, true); + assert.equal(report.healthyForLive, false); + + const text = formatDoctorReport(report); + assert.match(text, /model\s+render openai\/gpt-5\.4 \/ compile openai\/gpt-5\.4/); + assert.match(text, /model warning\s+explicit temperature/); + assert.match(text, /live:\s+not ready/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); /** diff --git a/packages/reactor-cli/src/__tests__/provider-config.test.ts b/packages/reactor-cli/src/__tests__/provider-config.test.ts index 4597f55c..97c3f533 100644 --- a/packages/reactor-cli/src/__tests__/provider-config.test.ts +++ b/packages/reactor-cli/src/__tests__/provider-config.test.ts @@ -4,7 +4,7 @@ import { cpSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { loadConfig } from '../config'; +import { loadConfig, modelCompatibilityWarnings } from '../config'; import { runCompileCommand } from '../commands/compile'; const SDK_ROOT = join(require.resolve('@openprose/reactor'), '..', '..'); @@ -112,6 +112,80 @@ describe('model temperature / reasoning_effort config parsing', () => { }); }); +describe('model compatibility warnings', () => { + it('warns when a reasoning-class OpenAI model has an explicit temperature', () => { + const warnings = modelCompatibilityWarnings({ + provider: 'openrouter', + render_model: 'openai/gpt-5.4', + compile_model: 'openai/gpt-5.4', + temperature: 0, + max_turns: 200, + }); + assert.equal(warnings.length, 1); + assert.match(warnings[0]!, /explicit temperature/); + assert.match(warnings[0]!, /openai\/gpt-5\.4/); + }); + + it('does not warn when reasoning_effort is none', () => { + const warnings = modelCompatibilityWarnings({ + provider: 'openrouter', + render_model: 'openai/gpt-5.4', + compile_model: 'openai/gpt-5.4', + temperature: 0, + reasoning_effort: 'none', + max_turns: 200, + }); + assert.deepEqual(warnings, []); + }); + + it('does not warn for non-reasoning models with explicit temperature', () => { + const warnings = modelCompatibilityWarnings({ + provider: 'openrouter', + render_model: 'google/gemini-3.5-flash', + compile_model: 'google/gemini-3.5-flash', + temperature: 0, + max_turns: 200, + }); + assert.deepEqual(warnings, []); + }); + + it('warns for Claude 4-class models with explicit temperature', () => { + const warnings = modelCompatibilityWarnings({ + provider: 'openrouter', + render_model: 'anthropic/claude-sonnet-4.6', + compile_model: 'anthropic/claude-sonnet-4.6', + temperature: 0, + max_turns: 200, + }); + assert.equal(warnings.length, 1); + assert.match(warnings[0]!, /claude-sonnet-4\.6/); + }); + + it('warns for Claude Haiku 4-class models with explicit temperature', () => { + const warnings = modelCompatibilityWarnings({ + provider: 'openrouter', + render_model: 'anthropic/claude-haiku-4-5', + compile_model: 'anthropic/claude-haiku-4-5', + temperature: 0, + max_turns: 200, + }); + assert.equal(warnings.length, 1); + assert.match(warnings[0]!, /claude-haiku-4-5/); + }); + + it('warns for o-series models beyond the initially known ids', () => { + const warnings = modelCompatibilityWarnings({ + provider: 'openrouter', + render_model: 'openai/o7-mini', + compile_model: 'openai/o7-mini', + temperature: 0, + max_turns: 200, + }); + assert.equal(warnings.length, 1); + assert.match(warnings[0]!, /o7-mini/); + }); +}); + describe('reactor compile — custom provider, missing key', () => { it('exits NON-ZERO with the exact env var when a custom provider has no key (DEFECT B / stranded user)', async () => { // Delete the hermetic key just in case a prior run leaked it into the env. diff --git a/packages/reactor-cli/src/__tests__/run.test.ts b/packages/reactor-cli/src/__tests__/run.test.ts index 4d718d60..ddd7dfe5 100644 --- a/packages/reactor-cli/src/__tests__/run.test.ts +++ b/packages/reactor-cli/src/__tests__/run.test.ts @@ -55,6 +55,28 @@ const MONITOR = 'competitor-monitor'; const BRIEF = 'weekly-brief'; const FUNDING_PATH = 'state/funding.json'; const BRIEF_PATH = 'state/brief.md'; +const REQUEST_INBOX = 'request-inbox'; +const CONTEXT_BRIEF = 'context-brief'; +const CONTEXT_EXAMPLE_SRC = join( + __dirname, + '..', + '..', + '..', + '..', + 'skills/open-prose/examples/context-boundary/src', +); +const REQUEST_INGRESS = `${REQUEST_INBOX}::ingress`; +const REQUEST_PATH = 'state/request.json'; +const CONTEXT_BRIEF_PATH = 'state/brief.md'; +const CONTEXT_REQUEST_ID = 'ctx-eval-001'; +const CONTEXT_SOURCE_REVISION = '7059b90'; +const CONTEXT_REQUEST = { + id: CONTEXT_REQUEST_ID, + goal: 'Summarize what the bounded Context section authorizes.', + source_revision: CONTEXT_SOURCE_REVISION, +}; + +const TEXT_ENCODER = new TextEncoder(); // The canned per-step compile outputs (verbatim from the SDK run-project.test.ts). const FORME_OUTPUT = JSON.stringify({ @@ -86,6 +108,40 @@ const BRIEF_CANON_OUTPUT = JSON.stringify({ facets: [], }); +const CONTEXT_FORME_OUTPUT = JSON.stringify({ + nodes: [ + { + id: REQUEST_INBOX, + kind: 'gateway', + wake_source: 'external', + requires: [], + maintains: ['request'], + }, + { + id: CONTEXT_BRIEF, + kind: 'responsibility', + wake_source: 'input', + requires: [{ facet: 'request' }], + maintains: ['brief'], + }, + ], + matches: [ + { subscriber: CONTEXT_BRIEF, requirement: 'request', producer: REQUEST_INBOX, facet: 'request' }, + ], +}); + +const REQUEST_CANON_OUTPUT = JSON.stringify({ + fields: [{ path: 'request', material: true }], + default_material: true, + facets: [{ facet: 'request', paths: ['request'] }], +}); + +const CONTEXT_BRIEF_CANON_OUTPUT = JSON.stringify({ + fields: [{ path: 'brief', material: true }], + default_material: true, + facets: [], +}); + /** The per-step compile providers (the offline cache-populate seam). */ function testCompileOptions() { return { @@ -103,6 +159,24 @@ function testCompileOptions() { }; } +function contextCompileOptions() { + return { + testSkill: 'TEST SKILL', + testProviders: { + forme: fakeStructuredProvider(CONTEXT_FORME_OUTPUT), + canonicalizer: { + [REQUEST_INBOX]: fakeStructuredProvider(REQUEST_CANON_OUTPUT), + [CONTEXT_BRIEF]: fakeStructuredProvider(CONTEXT_BRIEF_CANON_OUTPUT), + }, + skipPostconditions: true as const, + }, + }; +} + +function explicitContextProject(): string { + return CONTEXT_EXAMPLE_SRC; +} + /** The FAKE render: writes each node's workspace truth (what the live render's * tool loop would), returns a `done` RenderProduct whose world_model is the * harvest. Mirrors run-project.test.ts buildFakeRender. */ @@ -131,6 +205,59 @@ function buildFakeRender(store: WorldModelStore) { }; } +function buildContextFakeRender(store: WorldModelStore) { + return async (ctx: { + node: string; + wake: { source: string }; + inbound_edges: readonly { producer: string; facet: string }[]; + input_fingerprints: readonly string[]; + }) => { + if (ctx.node === REQUEST_INBOX) { + const staged = store.read(REQUEST_INGRESS, 'published').files['inbox.json']; + assert.ok(staged, 'trigger --data must be staged through the ingress source'); + const inbox = JSON.parse(readTextFile(staged as Uint8Array)) as unknown[]; + assert.equal(inbox.length, 1, 'the trigger payload lands exactly once'); + const request = inbox[0] as typeof CONTEXT_REQUEST; + assert.equal(request.id, CONTEXT_REQUEST_ID); + store.writeWorkspace(ctx.node, { + [REQUEST_PATH]: TEXT_ENCODER.encode(JSON.stringify({ request }, null, 2)), + }); + } else if (ctx.node === CONTEXT_BRIEF) { + assert.equal(ctx.wake.source, 'input', 'Context brief must wake from the request facet'); + assert.deepEqual( + ctx.inbound_edges.map((edge) => `${edge.producer}:${edge.facet}`), + [`${REQUEST_INBOX}:request`], + ); + assert.ok(ctx.input_fingerprints.length > 0, 'the staged request moved the input fingerprint'); + const requestBytes = store.read(REQUEST_INBOX, 'published').files[REQUEST_PATH]; + assert.ok(requestBytes, 'Context brief must read request-inbox published truth'); + const { request } = JSON.parse(readTextFile(requestBytes as Uint8Array)) as { + request: typeof CONTEXT_REQUEST; + }; + store.writeWorkspace(ctx.node, { + [CONTEXT_BRIEF_PATH]: TEXT_ENCODER.encode( + [ + `Context-bound brief for request ${request.id}.`, + `Source revision: ${request.source_revision}.`, + 'Used only declared Context and upstream request truth.', + ].join('\n'), + ), + }); + } else { + throw new Error(`unexpected context render node ${ctx.node}`); + } + return { + world_model: store.read(ctx.node, 'workspace').files, + cost: { + provider: 'fake', + model: 'fake', + tokens: { fresh: 1, reused: 0 }, + surprise_cause: ctx.wake.source, + }, + }; + }; +} + function freshDirs() { return { state: mkdtempSync(join(tmpdir(), 'reactor-cli-run-')), @@ -528,6 +655,87 @@ describe('reactor trigger (offline gate)', () => { } }); + it('stages --data, propagates request truth, and renders a Context-grounded subscriber', async () => { + const projectDir = explicitContextProject(); + const d = freshDirs(); + const storage = createMemoryStorageAdapter(); + const worldModel = new FileSystemWorldModelStore({ + directory: join(d.state, 'world-models'), + }); + try { + const out = capture(); + const code = await runTriggerCommand( + { + node: REQUEST_INBOX, + data: JSON.stringify(CONTEXT_REQUEST), + projectDir, + stateDir: d.state, + json: true, + testAdapters: { + clock: createSystemClockAdapter(), + storage, + worldModel, + }, + testRender: { buildRender: buildContextFakeRender as never }, + testCompileOptions: contextCompileOptions() as never, + }, + out.write, + ); + assert.equal(code, 0); + + const report = JSON.parse(out.lines.join('\n')) as { + status: string; + data: { id: string; source_revision: string }; + dispositions: { node: string; disposition: string }[]; + receipts: number; + }; + assert.equal(report.status, 'triggered'); + assert.deepEqual( + report.dispositions.map((x) => `${x.node}:${x.disposition}`).sort(), + [`${CONTEXT_BRIEF}:rendered`, `${REQUEST_INBOX}:rendered`], + ); + assert.equal(report.data.id, CONTEXT_REQUEST_ID); + assert.equal(report.data.source_revision, CONTEXT_SOURCE_REVISION); + assert.ok(report.receipts >= 3, 'ingress, gateway, and subscriber receipts landed'); + + const requestTruth = worldModel.read(REQUEST_INBOX, 'published').files[REQUEST_PATH]; + assert.ok(requestTruth, 'request gateway published structured truth'); + assert.equal( + (JSON.parse(readTextFile(requestTruth as Uint8Array)) as { request: { id: string } }) + .request.id, + CONTEXT_REQUEST_ID, + ); + + const briefTruth = worldModel.read(CONTEXT_BRIEF, 'published').files[CONTEXT_BRIEF_PATH]; + assert.ok(briefTruth, 'Context subscriber published its brief'); + const brief = readTextFile(briefTruth as Uint8Array); + assert.match(brief, new RegExp(CONTEXT_REQUEST_ID)); + assert.match(brief, new RegExp(CONTEXT_SOURCE_REVISION)); + + const receipts = storage.listReceipts(); + assert.ok( + receipts.some((r) => r.node === REQUEST_INGRESS && r.wake.source === 'external'), + 'the staged ingress receipt is persisted', + ); + const requestReceipt = receipts.find((r) => r.node === REQUEST_INBOX && r.status === 'rendered'); + assert.equal(requestReceipt?.wake.source, 'external'); + const briefReceipt = receipts.find((r) => r.node === CONTEXT_BRIEF && r.status === 'rendered'); + assert.equal(briefReceipt?.wake.source, 'input'); + assert.ok( + (briefReceipt?.input_fingerprints ?? []).length > 0, + 'the Context subscriber consumed a non-empty request fingerprint tuple', + ); + + for (const node of [REQUEST_INGRESS, REQUEST_INBOX, CONTEXT_BRIEF]) { + const chain = receipts.filter((r) => r.node === node); + const verification = verifyReceiptChain(chain as never); + assert.equal(verification.ok, true, `node ${node} receipt chain must verify`); + } + } finally { + rmSync(d.state, { recursive: true, force: true }); + } + }); + it('rejects an unknown node', async () => { const d = freshDirs(); try { diff --git a/packages/reactor-cli/src/commands/doctor.ts b/packages/reactor-cli/src/commands/doctor.ts index 08c63f09..8da4af1e 100644 --- a/packages/reactor-cli/src/commands/doctor.ts +++ b/packages/reactor-cli/src/commands/doctor.ts @@ -21,7 +21,12 @@ import { hasModelKey, readModelKey, isOfflineForced } from '../env'; import { checkLiveDeps, resolveSdk } from '../meta'; -import { loadConfig, type ConfigOverrides, type SandboxMode } from '../config'; +import { + loadConfig, + modelCompatibilityWarnings, + type ConfigOverrides, + type SandboxMode, +} from '../config'; import { resolveProviderPlan, type ProviderPlan, @@ -73,6 +78,12 @@ export interface DoctorReport { sdk: { resolved: boolean; version?: string }; /** The configured provider label + the env var its key is read from. */ provider: string; + /** Configured render/compile model ids plus keyless compatibility warnings. */ + model: { + renderModel: string; + compileModel: string; + compatibilityWarnings: string[]; + }; apiKeyEnv: string; liveKeyPresent: boolean; liveDeps: { name: string; present: boolean }[]; @@ -240,18 +251,25 @@ export async function collectDoctorReport( }; } const liveKeyPresent = hasModelKey(providerPlan.apiKeyEnv); + const compatibilityWarnings = modelCompatibilityWarnings(config.model); // Cleared to spend a key: everything a live `compile`/`run` render needs. const healthyForLive = healthyForOffline && liveKeyPresent && liveDeps.every((d) => d.present) && skill.present && - stateWritable; + stateWritable && + compatibilityWarnings.length === 0; const report: DoctorReport = { node: { version, major, ok: major >= MIN_NODE_MAJOR }, sdk: { resolved: sdk.resolved, version: sdk.version }, provider: providerPlan.provider, + model: { + renderModel: config.model.render_model, + compileModel: config.model.compile_model, + compatibilityWarnings, + }, apiKeyEnv: providerPlan.apiKeyEnv, liveKeyPresent, liveDeps, @@ -463,6 +481,12 @@ export function formatDoctorReport(report: DoctorReport): string { lines.push( ` live key ${report.liveKeyPresent ? `present (${report.apiKeyEnv})` : `absent (${report.apiKeyEnv})`}`, ); + lines.push( + ` model render ${report.model.renderModel} / compile ${report.model.compileModel}`, + ); + for (const warning of report.model.compatibilityWarnings) { + lines.push(` model warning ${warning}`); + } for (const dep of report.liveDeps) { lines.push(` live dep ${dep.name}: ${mark(dep.present)}`); } @@ -506,7 +530,9 @@ export function formatDoctorReport(report: DoctorReport): string { lines.push( report.healthyForLive ? ' live: READY — key + model peers + SKILL present; `reactor compile`/`run` can render' - : ' live: not ready — needs a key + `@openai/agents`+`zod` + the SKILL bundle (the keyless surface works without them)', + : report.model.compatibilityWarnings.length > 0 + ? ' live: not ready — fix model compatibility warnings above before spending a live render' + : ' live: not ready — needs a key + `@openai/agents`+`zod` + the SKILL bundle (the keyless surface works without them)', ); return lines.join('\n'); } diff --git a/packages/reactor-cli/src/commands/trigger.ts b/packages/reactor-cli/src/commands/trigger.ts index 901705d4..46e05724 100644 --- a/packages/reactor-cli/src/commands/trigger.ts +++ b/packages/reactor-cli/src/commands/trigger.ts @@ -16,11 +16,11 @@ * `--data` is parsed (JSON inline or `@file`) and validated here. The SDK `Wake` * shape carries NO payload slot (`{ source, refs }` only — architecture.md §6.1), * so a payload cannot be smuggled into the wake. The architecturally-sanctioned - * delivery is the SAME staging mechanism the connector ingress uses (cli.md §6.1): - * augment the triggered node's topology with a phantom-ingress edge, STAGE the - * `--data` into that ingress inbox (moving the node's input fingerprint), then - * ingest — so the wake is a memo-MISS and the node re-renders reading the staged - * payload. With NO `--data`, the trigger is a bare external wake (unchanged). + * delivery is the SAME staging mechanism the connector ingress uses (cli.md §6.1), + * and it is only valid for external gateway nodes: augment the gateway topology + * with a phantom-ingress edge, STAGE the `--data` into that ingress inbox + * (moving the gateway's input fingerprint), then ingest. With NO `--data`, the + * trigger is a bare external wake for any compiled node. */ import * as fs from 'fs'; @@ -230,7 +230,8 @@ export async function runTriggerCommand( // Load + re-lower (KEYLESS). const loaded = loadCompiledProject(stateDir); - if (loaded.ir.topology.topology.nodes.every((n) => n.node !== options.node)) { + const triggerNode = loaded.ir.topology.topology.nodes.find((n) => n.node === options.node); + if (triggerNode === undefined) { fireTrigger('failure'); return emitError( write, @@ -245,13 +246,21 @@ export async function runTriggerCommand( // (crosscheck dt-receiptspath-1). // // PAYLOAD DELIVERY (B3): the SDK `Wake` carries no payload, so when `--data` is - // given we deliver it via the connector STAGING mechanism — give the node a - // phantom-ingress edge so a staged arrival moves its input fingerprint. The - // compiled topology is augmented BEFORE the mount (so the reconciler resolves - // the ingress input), then we stage the parsed `--data` and ingest, which is a - // memo-MISS and re-renders the node reading the staged payload. With NO `--data` - // the topology + wake are unchanged (a bare external wake). + // given we deliver it via the connector STAGING mechanism. That delivery is + // only valid for external gateway nodes. A non-gateway trigger may still wake + // the node, but it cannot receive a hidden body; model the payload as gateway + // maintained truth and let downstream nodes subscribe via Requires. const hasData = options.data !== undefined; + if (hasData && triggerNode.wake_source !== 'external') { + fireTrigger('failure'); + return emitError( + write, + options.json, + `reactor trigger: --data can only be delivered to an external gateway node; ` + + `'${options.node}' has wake_source '${triggerNode.wake_source}'. ` + + `Trigger a gateway and connect downstream data with Requires/Maintains.`, + ); + } const compiledForMount = hasData ? { ...loaded.compiled, @@ -309,12 +318,12 @@ export async function runTriggerCommand( options.testRunProjectImpl, ); - // When `--data` is given, STAGE it into the node's phantom-ingress inbox so the - // upcoming ingest is a memo-miss that delivers the payload (B3). `buildStageArrival` - // appends the item to the ingress source's published inbox, commits it (moving the - // ingress atomic fingerprint), and appends an EXTERNAL receipt — which moves the - // node's `input_fingerprints`. The arrival id is content-stable so a re-trigger of - // the same payload dedups at the inbox (append-style stage). + // When `--data` is given, STAGE it into the gateway's phantom-ingress inbox so + // the upcoming ingest is a memo-miss that delivers the payload (B3). + // `buildStageArrival` appends the item to the ingress source's published inbox, + // commits it (moving the ingress atomic fingerprint), and appends an EXTERNAL + // receipt — which moves the gateway's `input_fingerprints`. The arrival id is + // content-stable so a re-trigger of the same payload dedups at the inbox. if (hasData) { // The typed handle surfaces `store`/`ledger` first-class — no cast. The // `StageStore`/`StageLedger` params are narrow structural views the SDK diff --git a/packages/reactor-cli/src/compile/contract-images.ts b/packages/reactor-cli/src/compile/contract-images.ts index 379da265..51b368af 100644 --- a/packages/reactor-cli/src/compile/contract-images.ts +++ b/packages/reactor-cli/src/compile/contract-images.ts @@ -124,6 +124,7 @@ export function sliceContract(text: string, path: string): ContractImage { const out: Record = { id, name, kind }; if (sections['Requires'] !== undefined) out['requires'] = sections['Requires']; + if (sections['Context'] !== undefined) out['context'] = sections['Context']; if (sections['Maintains'] !== undefined) out['maintains'] = sections['Maintains']; if (sections['Continuity'] !== undefined) out['continuity'] = sections['Continuity']; if (sections['Execution'] !== undefined) out['execution'] = sections['Execution']; @@ -184,7 +185,7 @@ function splitSections(body: string): Record { const h2OrH1 = /^#{1,2}\s+/.exec(line); if (h3 !== null) { flush(); - current = firstWord(h3[1] ?? ''); + current = canonicalSectionKey(firstWord(h3[1] ?? '')); continue; } if (h2OrH1 !== null) { @@ -205,6 +206,23 @@ function firstWord(heading: string): string { return match ? (match[1] ?? heading) : heading; } +function canonicalSectionKey(section: string): string { + switch (section.toLowerCase()) { + case 'requires': + return 'Requires'; + case 'context': + return 'Context'; + case 'maintains': + return 'Maintains'; + case 'continuity': + return 'Continuity'; + case 'execution': + return 'Execution'; + default: + return section; + } +} + function normalizeKind(value: string | undefined): string { switch (value) { case 'responsibility': diff --git a/packages/reactor-cli/src/compile/ir-cache.ts b/packages/reactor-cli/src/compile/ir-cache.ts index 0f304ee94e8478727181812dbd0a4ee093eecc23..0abe7db44861a85b2215816162e7f1d89561964d 100644 GIT binary patch delta 633 zcmX?;_B(gOaaPad{JfH)#N-m)vdq--V!f>5{JaDO2zb;mMWI?DFFz$!;ZfTph0MI- zlA_Y&lFa-(-JHbA)FK6#UImce&AVB-SR4vci*&&{2&h&7DlE<{E=f&MNXsu$C@Rg< zFHS8gOPwswFD4!UlXHeTE(~bCLiJ=Ran;D&#LT=BATG92C`wIC$Clh6R8y?_LBw0MJG=cH`%<4^E=DrJU$KI^dYhVEYs@LnT delta 124 zcmV-?0E7SgZOmq{#|5(-2L1!HSqtn0vyc#|0h5prACu-IQnU0DXacj>6+{G+tO^OU zK^olwBW+=6ZggR3ZgVeVZ*FvHZgpvNc`s#nWn*=8X>V>X3Lq*VlSv#uleZfUlb9UR e0RXdX9Z3PR*B-nAv(F*T46_U`V*|5-GI<0{kuU84 diff --git a/packages/reactor-cli/src/compile/run-compile.ts b/packages/reactor-cli/src/compile/run-compile.ts index 37e176e7..76fbf093 100644 --- a/packages/reactor-cli/src/compile/run-compile.ts +++ b/packages/reactor-cli/src/compile/run-compile.ts @@ -23,6 +23,7 @@ import type { ContractImage, + PersistedContractView, PersistedCost, PersistedPostcondition, SerializableCompileIR, @@ -124,6 +125,7 @@ export async function runCompile(options: CompileRunOptions): Promise = { id: c.id, name: c.name, kind: c.kind }; if (c.requires !== undefined) out['requires'] = c.requires; + if (c.context !== undefined) out['context'] = c.context; if (c.maintains !== undefined) out['maintains'] = c.maintains; if (c.continuity !== undefined) out['continuity'] = c.continuity; if (c.execution !== undefined) out['execution'] = c.execution; return out as unknown as ContractImage; } +function deriveContractViews( + contracts: readonly LoadedContractLike[], +): Record { + const out: Record = {}; + for (const c of contracts) { + out[c.id] = { + name: c.name, + maintains: c.maintains !== undefined ? [c.maintains] : [], + requires: c.requires !== undefined ? [c.requires] : [], + ...(c.context !== undefined ? { context: c.context } : {}), + ...(c.continuity !== undefined ? { continuity: c.continuity } : {}), + ...(c.execution !== undefined ? { execution: c.execution } : {}), + }; + } + return out; +} + /** * Derive the per-node contract fingerprints the same way the SDK does * (run-project.ts `deriveContractFingerprints` — `contentAddressOf` over a fixed @@ -304,6 +326,7 @@ function deriveContractFingerprints( `name:${c.name}`, `kind:${c.kind}`, `requires:${c.requires ?? ''}`, + `context:${c.context ?? ''}`, `maintains:${c.maintains ?? ''}`, `continuity:${c.continuity ?? ''}`, `execution:${c.execution ?? ''}`, diff --git a/packages/reactor-cli/src/config.ts b/packages/reactor-cli/src/config.ts index d6d7cd37..179af2d0 100644 --- a/packages/reactor-cli/src/config.ts +++ b/packages/reactor-cli/src/config.ts @@ -196,6 +196,44 @@ export function loadConfig(overrides: ConfigOverrides = {}): ReactorConfig { }; } +/** + * Keyless model-config compatibility warnings. These are preflight heuristics for + * known provider failure classes, not a substitute for `reactor doctor --live`. + * The load-bearing case: reasoning-class model ids often reject an explicit + * temperature unless `reasoning_effort: none` is set. Deleting `temperature:` is + * the most portable fix because the request then omits the key entirely. + */ +export function modelCompatibilityWarnings(model: ModelConfig): string[] { + const warnings: string[] = []; + if ( + model.temperature !== undefined && + (model.reasoning_effort ?? '').toLowerCase() !== 'none' + ) { + const reasoningModels = [model.compile_model, model.render_model] + .filter(isReasoningClassModelId) + .filter((value, index, values) => values.indexOf(value) === index); + if (reasoningModels.length > 0) { + warnings.push( + `explicit temperature is configured for reasoning-class model(s) ` + + `${reasoningModels.join(', ')}. Some providers reject this unless ` + + `reasoning_effort is 'none'. Delete the temperature line to omit it, ` + + `or set reasoning_effort: none if the provider supports that mode.`, + ); + } + } + return warnings; +} + +function isReasoningClassModelId(modelId: string): boolean { + const id = modelId.trim().toLowerCase(); + const leaf = id.split('/').pop() ?? id; + return ( + /^gpt-5(?:[.\-]|$)/.test(leaf) || + /^o\d+(?:[.\-]|$)/.test(leaf) || + /^claude-(?:haiku|opus|sonnet)-4(?:[.\-]|$)/.test(leaf) + ); +} + /** * Validate that a resolved `--state-dir` target is usable as a DIRECTORY before a * command tries to `mkdir` it (the durable substrate + world-model store both diff --git a/packages/reactor-cli/src/run/run-core.ts b/packages/reactor-cli/src/run/run-core.ts index c049b28f..5ce9e3f6 100644 --- a/packages/reactor-cli/src/run/run-core.ts +++ b/packages/reactor-cli/src/run/run-core.ts @@ -351,16 +351,19 @@ function projectStructuredJson(files: ProjectFiles): ProjectTruth { /** * A minimal per-node `CompiledContractView`. The cache does not persist the - * loaded contract source (only the re-lowerable spec + fingerprints), so the - * run/serve path supplies a coarse view derived from the topology: the node id - * as the name + its maintained/required facets read off the spec + edges. The - * load-bearing run-time behavior (materiality, propagation) comes from the - * COMPILED canonicalizer, not this view (the view is the instruction layer, only - * material on the LIVE render path; the offline gate uses a fake render that - * ignores it). For an offline run this is sufficient; a live `run` would - * typically re-compile to recover the rich contract view. + * loaded contract source in older caches, so the fallback supplies a coarse view + * derived from the topology: the node id as the name + its maintained/required + * facets read off the spec + edges. Current caches persist the richer + * instruction-layer view, including `### Context`; the fallback keeps older + * caches loadable. The load-bearing run-time behavior (materiality, + * propagation) comes from the COMPILED canonicalizer, not this view. */ export function contractViewFor(ir: LoadedCompileIR, node: string): ContractView { + const persisted = ir.contractViews[node]; + if (persisted !== undefined) { + return persisted; + } + const entry = ir.perNode[node]; const maintains = entry?.compiled.canonicalizer.facets ?? []; const requires = ir.topology.topology.edges diff --git a/packages/reactor/src/adapters/agent-compile/__tests__/contract-loader.test.ts b/packages/reactor/src/adapters/agent-compile/__tests__/contract-loader.test.ts index 793b0f36..a90b9644 100644 --- a/packages/reactor/src/adapters/agent-compile/__tests__/contract-loader.test.ts +++ b/packages/reactor/src/adapters/agent-compile/__tests__/contract-loader.test.ts @@ -27,6 +27,9 @@ Intro prose the session reads. ### Requires - a current view of the market +### Context +- style-guide: read-only style rules for the rendered summary. + ### Maintains A corroborated view of each competitor. @@ -54,10 +57,20 @@ test("sliceContract: reads frontmatter id/name/kind (flat scalars only)", () => test("sliceContract: splits the body into verbatim ### sections", () => { const c = sliceContract(SAMPLE, "/x/c.prose.md"); ok(c.requires?.includes("a current view of the market")); + ok(c.context?.includes("style-guide")); ok(c.continuity?.includes("daily forecast cadence")); ok(c.execution?.includes("Fetch sources")); }); +test("sliceContract: recognized Contract Markdown sections are case-insensitive", () => { + const c = sliceContract( + "---\nname: mixed-case\nkind: responsibility\n---\n\n### context\n- lowercase context\n\n### MAINTAINS\n- uppercase maintains\n", + "/x/mixed-case.prose.md", + ); + equal(c.context, "- lowercase context"); + equal(c.maintains, "- uppercase maintains"); +}); + test("sliceContract: #### facet parts stay INSIDE the ### Maintains body (the session sees them)", () => { const c = sliceContract(SAMPLE, "/x/c.prose.md"); ok(c.maintains?.includes("#### funding"), "funding facet stays in Maintains"); @@ -100,6 +113,8 @@ test("renderContractSet: lays out every contract's identity + sections as stable const text = renderContractSet([a]); ok(text.includes("Contract `competitor-activity`")); ok(text.includes("kind: responsibility")); + ok(text.includes("### Context")); + ok(text.includes("style-guide")); ok(text.includes("### Maintains")); ok(text.includes("#### funding")); // deterministic: rendering twice is byte-identical diff --git a/packages/reactor/src/adapters/agent-compile/contract-loader.ts b/packages/reactor/src/adapters/agent-compile/contract-loader.ts index a0324ece..2ebc1d4a 100644 --- a/packages/reactor/src/adapters/agent-compile/contract-loader.ts +++ b/packages/reactor/src/adapters/agent-compile/contract-loader.ts @@ -13,9 +13,9 @@ * 1. ENUMERATE the contract set (a list of `.prose.md` files on disk), and * 2. SLICE each file into its frontmatter + its top-level `###` sections, so a * session is handed the contract's `### Requires` / `### Maintains` / - * `### Continuity` / `### Execution` text verbatim (a coarse heading split, - * not a semantic parse — the section bodies are opaque prose the SESSION - * reads and understands). + * `### Context` / `### Continuity` / `### Execution` text verbatim (a + * coarse heading split, not a semantic parse — the section bodies are + * opaque prose the SESSION reads and understands). * * The slicing is deliberately dumb: it finds `## name:` / `kind:` in the * frontmatter fence and splits the body on `^### ` headings. It assigns NO @@ -53,6 +53,8 @@ export interface LoadedContract { readonly kind: RenderKind; /** Verbatim `### Requires` body (absent ⇒ no upstream needs). */ readonly requires?: string; + /** Verbatim `### Context` body (bounded read-only context for the render). */ + readonly context?: string; /** Verbatim `### Maintains` body (the producer/canonicalization spec source). */ readonly maintains?: string; /** Verbatim `### Continuity` body (the intrinsic wake-source declaration). */ @@ -184,6 +186,7 @@ export function sliceContract(text: string, path: string): LoadedContract { // Assemble with only the present optional sections (exactOptionalPropertyTypes). const out: Record = { id, name, kind, path }; if (sections["Requires"] !== undefined) out["requires"] = sections["Requires"]; + if (sections["Context"] !== undefined) out["context"] = sections["Context"]; if (sections["Maintains"] !== undefined) out["maintains"] = sections["Maintains"]; if (sections["Continuity"] !== undefined) out["continuity"] = sections["Continuity"]; if (sections["Execution"] !== undefined) out["execution"] = sections["Execution"]; @@ -287,7 +290,7 @@ function splitSections(body: string): Record { const h2OrH1 = /^#{1,2}\s+/.exec(line); if (h3 !== null) { flush(); - current = firstWord(h3[1] ?? ""); + current = canonicalSectionKey(firstWord(h3[1] ?? "")); continue; } if (h2OrH1 !== null) { @@ -304,12 +307,29 @@ function splitSections(body: string): Record { return sections; } -/** The section key is its first word (`Requires`, `Maintains`, …). */ +/** The section key is its first word (`Requires`, `Maintains`, ...). */ function firstWord(heading: string): string { const match = /^(\S+)/.exec(heading.trim()); return match ? (match[1] ?? heading) : heading; } +function canonicalSectionKey(section: string): string { + switch (section.toLowerCase()) { + case "requires": + return "Requires"; + case "context": + return "Context"; + case "maintains": + return "Maintains"; + case "continuity": + return "Continuity"; + case "execution": + return "Execution"; + default: + return section; + } +} + function normalizeKind(value: string | undefined): RenderKind { switch (value) { case "responsibility": diff --git a/packages/reactor/src/adapters/agent-compile/contract-set-input.ts b/packages/reactor/src/adapters/agent-compile/contract-set-input.ts index 43d493c1..babe108b 100644 --- a/packages/reactor/src/adapters/agent-compile/contract-set-input.ts +++ b/packages/reactor/src/adapters/agent-compile/contract-set-input.ts @@ -16,7 +16,7 @@ import type { ContractSet, LoadedContract } from "./contract-loader"; /** * Render the whole contract set as the compile session's run input. Each * contract is a fenced block carrying its id/name/kind and its verbatim - * `### Requires` / `### Maintains` / `### Continuity` / `### Execution` + * `### Requires` / `### Context` / `### Maintains` / `### Continuity` / `### Execution` * sections. Contracts are emitted in the (already-sorted) set * order so the evidence is stable across runs (reproducibility). */ @@ -44,6 +44,7 @@ export function renderContract(contract: LoadedContract): string { lines.push(""); appendSection(lines, "Requires", contract.requires); + appendSection(lines, "Context", contract.context); appendSection(lines, "Maintains", contract.maintains); appendSection(lines, "Continuity", contract.continuity); appendSection(lines, "Execution", contract.execution); diff --git a/packages/reactor/src/adapters/agent-render/__tests__/instructions.test.ts b/packages/reactor/src/adapters/agent-render/__tests__/instructions.test.ts new file mode 100644 index 00000000..b22ff3cb --- /dev/null +++ b/packages/reactor/src/adapters/agent-render/__tests__/instructions.test.ts @@ -0,0 +1,46 @@ +import { equal, match } from "node:assert/strict"; +import { test } from "node:test"; + +import { + composeNodeContract, + composeWakeHeader, + type CompiledContractView, +} from "../instructions"; + +test("composeNodeContract includes Context before Maintains", () => { + const contract: CompiledContractView = { + name: "Digest", + requires: ["accepted items"], + context: "- `style`: read-only wording guidance", + maintains: ["`digest`: one paragraph"], + }; + + const text = composeNodeContract("digest", contract); + + match(text, /### Context\n- `style`: read-only wording guidance/); + equal(text.indexOf("\n### Context\n") < text.indexOf("\n### Maintains\n"), true); +}); + +test("composeWakeHeader names resolved upstream producers and points to upstream tools", () => { + const text = composeWakeHeader({ + node: "context-brief", + contract_fingerprint: "sha256:abc", + wake: { source: "input", refs: [] }, + input_fingerprints: ["fp:request"], + inbound_edges: [{ producer: "request-inbox", facet: "request" }], + prior: { + ref: { + node: "context-brief", + workspace: "published", + location: "/state/context-brief/published.json", + version: null, + }, + files: {}, + }, + } as never); + + match(text, /### Upstream truth you may read/); + match(text, /producer `request-inbox`, facet `request`/); + match(text, /wm_list_upstream/); + match(text, /wm_read_upstream/); +}); diff --git a/packages/reactor/src/adapters/agent-render/__tests__/tools.test.ts b/packages/reactor/src/adapters/agent-render/__tests__/tools.test.ts index ad5812f2..0d266d8a 100644 --- a/packages/reactor/src/adapters/agent-render/__tests__/tools.test.ts +++ b/packages/reactor/src/adapters/agent-render/__tests__/tools.test.ts @@ -1,5 +1,5 @@ import { asFacet } from "../../../shapes"; -import { equal, deepEqual, match, ok } from "node:assert/strict"; +import { equal, deepEqual, match, ok, doesNotMatch } from "node:assert/strict"; import { test } from "node:test"; import { RunContext, type FunctionTool } from "@openai/agents"; @@ -158,6 +158,9 @@ test("wm_list: cold start (no prior truth) returns a legible empty note", async test("wm_list_upstream: lists the (producer, facet) subscriptions this node has", async () => { const store = new InMemoryWorldModelStore(); + store.commitPublished("monitor", { + "state/funding.json": textFile('{"round":"A"}'), + }); const ctx = makeContext(store, { upstream: [ { producer: "monitor", facet: asFacet("funding") }, @@ -166,11 +169,20 @@ test("wm_list_upstream: lists the (producer, facet) subscriptions this node has" }); const out = await invokeTool(wmListUpstreamTool(), ctx, { producer: null }); // sorted, one (producer \t facet) per line - equal(out, "feed\t@atomic\nmonitor\tfunding"); + equal( + out, + "feed\t@atomic\t(no published files)\nmonitor\tfunding\tstate/funding.json", + ); + doesNotMatch(out, /round/); + doesNotMatch(out, /"A"/); }); test("wm_list_upstream: narrows to one producer's facets when given a producer", async () => { const store = new InMemoryWorldModelStore(); + store.commitPublished("monitor", { + "state/funding.json": textFile('{"round":"A"}'), + "state/hiring.json": textFile('{"roles":2}'), + }); const ctx = makeContext(store, { upstream: [ { producer: "monitor", facet: asFacet("funding") }, @@ -181,7 +193,11 @@ test("wm_list_upstream: narrows to one producer's facets when given a producer", const out = await invokeTool(wmListUpstreamTool(), ctx, { producer: "monitor", }); - equal(out, "monitor\tfunding\nmonitor\thiring"); + equal( + out, + "monitor\tfunding\tstate/funding.json, state/hiring.json\n" + + "monitor\thiring\tstate/funding.json, state/hiring.json", + ); }); test("wm_list_upstream: no subscriptions returns a legible empty note", async () => { diff --git a/packages/reactor/src/adapters/agent-render/instructions.ts b/packages/reactor/src/adapters/agent-render/instructions.ts index 4df0f970..b4cdd4fe 100644 --- a/packages/reactor/src/adapters/agent-render/instructions.ts +++ b/packages/reactor/src/adapters/agent-render/instructions.ts @@ -9,9 +9,9 @@ * 1. BASE SKILL — identical across every node; the open-prose system prompt * (loaded once, {@link readSkill}). This is the "by default" * injection: every render carries the same SKILL. - * 2. NODE CONTRACT — this node's compiled `### Requires` / `### Maintains` (+ - * its canonicalizer/continuity spec and `### Execution` - * ProseScript body). Layered AFTER the SKILL so node + * 2. NODE CONTRACT — this node's compiled `### Requires` / `### Context` / + * `### Maintains` (+ its canonicalizer/continuity spec and + * `### Execution` ProseScript body). Layered AFTER the SKILL so node * specifics refine the general teaching. * 3. WAKE HEADER — the ONLY per-render-varying layer, and it carries NO * truth — only POINTERS (the wake + where prior truth @@ -93,7 +93,7 @@ const LAYER_SEPARATOR = "\n\n---\n\n"; /** * The compiled-contract view the harness supplies per node. This is the - * lowered `### Requires` / `### Maintains` / `### Continuity` / `### Execution` + * lowered `### Requires` / `### Context` / `### Maintains` / `### Continuity` / `### Execution` * the render must satisfy — the contract layer of the instructions. Kept a small * plain shape (not a Forme dependency) so the slice can mount a hand-authored * node; a real compile phase produces a richer view later. @@ -105,6 +105,8 @@ export interface CompiledContractView { readonly maintains: readonly string[]; /** The `### Requires` upstream facet-contracts this render subscribes to. */ readonly requires: readonly string[]; + /** The `### Context` read-only render guidance carried by the source. */ + readonly context?: string; /** The `### Continuity` clause (when/why the node re-renders over time). */ readonly continuity?: string; /** @@ -137,10 +139,10 @@ export function readSkill(skillPath: string = DEFAULT_SKILL_PATH): string { } /** - * Build the NODE CONTRACT layer — the compiled `### Requires`/`### Maintains` - * (+ continuity + the `### Execution` ProseScript body). This tells *this* - * render what world-model schema to satisfy and what postconditions to leave - * true. + * Build the NODE CONTRACT layer — the compiled `### Requires`/`### Context`/ + * `### Maintains` (+ continuity + the `### Execution` ProseScript body). This + * tells *this* render what read-only guidance it may consult, what world-model + * schema to satisfy, and what postconditions to leave true. */ export function composeNodeContract( node: string, @@ -155,6 +157,12 @@ export function composeNodeContract( "upstream truth by reference through your tools.", ); + if (contract.context !== undefined && contract.context.length > 0) { + lines.push(""); + lines.push("### Context"); + lines.push(contract.context); + } + lines.push(""); lines.push("### Maintains"); if (contract.maintains.length === 0) { @@ -236,11 +244,32 @@ export function composeWakeHeader(ctx: RenderContext): string { ); } - // We do NOT enumerate the `wm_*` / sandbox tools here. The SDK + lines.push(""); + lines.push("### Upstream truth you may read"); + if (ctx.inbound_edges.length === 0) { + lines.push("This node subscribes to no upstream producers."); + } else { + const edges = [ + ...new Set( + ctx.inbound_edges.map((edge) => `${edge.producer}\t${edge.facet}`), + ), + ].sort(); + for (const edge of edges) { + const [producer, facet] = edge.split("\t"); + lines.push(`- producer \`${producer}\`, facet \`${facet}\``); + } + lines.push( + "Call `wm_list_upstream` to see each producer's published file paths, " + + "then call `wm_read_upstream` with the producer id and path you need.", + ); + } + + // We do NOT enumerate the full `wm_*` / sandbox tool catalog here. The SDK // advertises the available tools to the model via the native `tools` request - // field, not the prompt; hand-listing them in prose is redundant and a drift - // risk. The SKILL + node contract + this wake header are the only prompt - // layers — the render reads/writes through whatever tools the session is given. + // field. The upstream section above names only the two relevant read-by- + // reference helpers when inbound edges exist, because those producer/facet + // pointers are part of this render's wake context. The SKILL + node contract + + // this wake header are the only prompt layers. lines.push(""); lines.push("### How to render"); lines.push( diff --git a/packages/reactor/src/adapters/agent-render/tools.ts b/packages/reactor/src/adapters/agent-render/tools.ts index 8a6af005..ad46393b 100644 --- a/packages/reactor/src/adapters/agent-render/tools.ts +++ b/packages/reactor/src/adapters/agent-render/tools.ts @@ -325,8 +325,8 @@ function subscribedProducers(context: AgentRenderContext): Set { * legible note — not an error — for a producer the node does not subscribe to, so * the agent can probe without aborting the turn). This is how the render * DISCOVERS what upstream truth it may read before reading it - * (read-by-reference). It does NOT pre-stuff the upstream truth — only the - * pointers. + * (read-by-reference). It lists producer/facet pointers and published file paths. + * It does NOT pre-stuff upstream file contents. */ export function wmListUpstreamTool(): Tool { return tool({ @@ -334,8 +334,9 @@ export function wmListUpstreamTool(): Tool { description: "List the upstream producers (and their facets) this node subscribes to — " + "the only upstream truth you may read. Optionally pass a producer node id to " + - "narrow to that producer's subscribed facets. Use this to discover what " + - "upstream truth exists before reading it with wm_read_upstream.", + "narrow to that producer's subscribed facets. The listing includes the " + + "producer's published file paths, so use it to discover the path to pass to " + + "wm_read_upstream.", parameters: z.object({ producer: z .string() @@ -365,8 +366,18 @@ export function wmListUpstreamTool(): Tool { ); } // One line per (producer, facet), sorted + de-duplicated for a stable list. + // File paths are pointers only: contents still require wm_read_upstream. const lines = [ - ...new Set(selected.map((s) => `${s.producer}\t${s.facet}`)), + ...new Set( + selected.map((s) => { + const paths = Object.keys( + context.store.read(s.producer, "published").files, + ).sort(); + const pathList = + paths.length === 0 ? "(no published files)" : paths.join(", "); + return `${s.producer}\t${s.facet}\t${pathList}`; + }), + ), ].sort(); return lines.join("\n"); }, diff --git a/packages/reactor/src/sdk/__tests__/run-project.test.ts b/packages/reactor/src/sdk/__tests__/run-project.test.ts index 35d94deb..998770eb 100644 --- a/packages/reactor/src/sdk/__tests__/run-project.test.ts +++ b/packages/reactor/src/sdk/__tests__/run-project.test.ts @@ -44,6 +44,7 @@ import { } from "../index"; import { dispositionOf, lastReceipt } from "../../scenario/trace"; import { contractFingerprint } from "../../scenario/fixture"; +import { sliceContract } from "../../adapters/agent-compile"; import { fakeStructuredProvider } from "../../adapters/agent-compile/__tests__/fake-provider"; import { createOpenRouterProvider, @@ -244,6 +245,59 @@ test("compileProject: the on-disk two-node fixture compiles to a mountable topol equal(compiled.cost.surprise_cause, "self"); }); +test("compileProject: Context edits move the per-node contract fingerprint", async () => { + const node = "context-grounded-summary"; + const source = `--- +name: context-grounded-summary +kind: responsibility +--- + +### Context +- style guide A + +### Maintains +- summary +`; + const changed = source.replace("style guide A", "style guide B"); + const formeOutput = JSON.stringify({ + nodes: [ + { + id: node, + kind: "responsibility", + wake_source: "input", + requires: [], + maintains: [], + }, + ], + matches: [], + }); + const canonOutput = JSON.stringify({ + fields: [{ path: "summary", material: true }], + default_material: true, + facets: [], + }); + const pcOutput = JSON.stringify({ postconditions: [] }); + const compileOne = (text: string) => + compileProject({ + contracts: [sliceContract(text, `/x/${node}.prose.md`)], + options: { skill: "TEST SKILL" }, + perStep: { + forme: { provider: fakeStructuredProvider(formeOutput) }, + canonicalizer: { + byNode: { [node]: { provider: fakeStructuredProvider(canonOutput) } }, + }, + postcondition: { + byNode: { [node]: { provider: fakeStructuredProvider(pcOutput) } }, + }, + }, + }); + + const a = await compileOne(source); + const b = await compileOne(changed); + + notEqual(a.contractFingerprints[node], b.contractFingerprints[node]); +}); + test("compileProject skipPostconditions: synthesizes EMPTY validator sets without a postcondition session", async () => { // skipPostconditions runs Forme + the per-node canonicalizer sessions but NOT // the postcondition session — a deliberate opt-out a caller may choose. Here we diff --git a/packages/reactor/src/sdk/run-project.ts b/packages/reactor/src/sdk/run-project.ts index 61b5deab..3e865c54 100644 --- a/packages/reactor/src/sdk/run-project.ts +++ b/packages/reactor/src/sdk/run-project.ts @@ -588,6 +588,7 @@ function deriveContractFingerprints( `name:${contract.name}`, `kind:${contract.kind}`, `requires:${contract.requires ?? ""}`, + `context:${contract.context ?? ""}`, `maintains:${contract.maintains ?? ""}`, `continuity:${contract.continuity ?? ""}`, `execution:${contract.execution ?? ""}`, @@ -667,6 +668,9 @@ function defaultContractFor( const maintains = contract?.maintains ? [contract.maintains] : []; const requires = contract?.requires ? [contract.requires] : []; const view: Record = { name, maintains, requires }; + if (contract?.context !== undefined) { + view["context"] = contract.context; + } if (contract?.continuity !== undefined) { view["continuity"] = contract.continuity; } diff --git a/skills/open-prose/SKILL.md b/skills/open-prose/SKILL.md index 8cbf24e9..7414bdc7 100644 --- a/skills/open-prose/SKILL.md +++ b/skills/open-prose/SKILL.md @@ -219,6 +219,10 @@ Contract Markdown uses Markdown headers as the canonical human-facing syntax: - `topic`: the question to investigate +### Context + +- `style-guide`: read-only guidance to consult during the render + ### Maintains - `report`: concise answer with sources @@ -251,6 +255,12 @@ Header hierarchy: - `##` starts an inline contract in multi-contract files. - `###` starts a section inside the current responsibility or function. +Authoring rule for `### Context`: use it for bounded, read-only grounding such +as policy notes, style guides, source paths, or prior decisions. Do not use it +to carry runtime data. If data should wake a node, author a gateway that +`### Maintains` the incoming truth and a downstream responsibility that +`### Requires` the relevant facet. + ## File Locations All OpenProse skill files are colocated with this `SKILL.md`. Do not search the diff --git a/skills/open-prose/changelog.md b/skills/open-prose/changelog.md index 67a44d35..700eaaee 100644 --- a/skills/open-prose/changelog.md +++ b/skills/open-prose/changelog.md @@ -22,6 +22,10 @@ plan. support both `cli:` and `mcp:` and fail closed when the host cannot resolve a declared capability. Resolved responsibility tools are preserved in the compile-phase IR and run activation payloads. +- `### Context` declares bounded, read-only context a render may consult. It is + preserved in compile/run evidence and contract identity, but does not create + Forme dependency edges or satisfy `### Requires`, `### Maintains`, or + `### Returns`. - Every workspace has an active OpenProse root. - Native repositories use the repository root as the OpenProse root. - Attached repositories use `repo/.agents/prose`. diff --git a/skills/open-prose/contract-markdown.md b/skills/open-prose/contract-markdown.md index 20c17784..8bf32dda 100644 --- a/skills/open-prose/contract-markdown.md +++ b/skills/open-prose/contract-markdown.md @@ -208,6 +208,7 @@ Forme and the Prose VM recognize these `###` sections case-insensitively: | `### Description` | all | Human summary. Preserved for readers; not used as a contract | | `### Goal` | responsibility, gateway | The render's one-sentence standing intent | | `### Requires` | responsibility, pattern slots | Subscription contracts naming facet-level needs; a `####` sub-heading is a facet-need. Forme's match target (`Requires. ↔ Maintains.`) | +| `### Context` | responsibility, function | Declared read-only context the render may consult. It is preserved for compile/run evidence and contract identity, but it does not create Forme edges or satisfy `### Requires`, `### Maintains`, or `### Returns` | | `### Maintains` | responsibility, gateway | The world-model **schema** — type, canonicalization spec, facets (a `####` sub-heading is a facet, the named-parts rule), and postconditions (see [Maintains](#maintains)) | | `### Parameters` | function | Inputs the caller passes at call time | | `### Returns` | function | The value the function returns | @@ -379,6 +380,31 @@ with the human); the resolved producer is Forme's choice (mechanism). Load `responsibility-runtime.md` and `concepts/responsibility.md` for the compile/run reconciler semantics. +## Context + +`### Context` declares bounded, read-only context the render may consult while +doing its work: style guides, policy notes, domain glossaries, source paths, +prior decisions, or operator guidance. It is part of the source contract, so +compilers and harnesses preserve it in the contract image they hand to the +render and include it when deriving contract identity. + +`### Context` is not dependency injection. It does not subscribe to another +responsibility, does not wake a node, and does not publish truth. Use +`### Requires` for subscribed inputs, `### Maintains` for responsibility output +truth, and `### Returns` for function results. A source with only `### Context` +and no `### Maintains`/`### Returns` is still missing its output contract. + +For Reactor-triggered work, incoming JSON or webhook payloads belong in a +`kind: gateway` that `### Maintains` normalized ingress truth. Downstream +responsibilities then read that truth through `### Requires` and the compiled +edge Forme draws. `### Context` may explain how to interpret the request, name a +source path, or bound a policy decision, but the payload itself is not ambient +context and should not be treated as if it magically appeared in the render. + +Harnesses that claim Context support must avoid silently adding undeclared +ambient parent-session context to a child render. When a child activation is +used, the launch artifact should make the declared context boundary inspectable. + ## Maintains `### Maintains` declares the **shape** of the world-model — the schema, not the diff --git a/skills/open-prose/examples/README.md b/skills/open-prose/examples/README.md index b51fe606..099f30a2 100644 --- a/skills/open-prose/examples/README.md +++ b/skills/open-prose/examples/README.md @@ -59,6 +59,9 @@ reconciler offline, asserting the property each one teaches. - [research-tree](./research-tree/): propagation UP a recursive tree with per-branch memoization: revising one leaf wakes only its ancestor path; siblings stay dark. +- [context-boundary](./context-boundary/): a gateway-triggered request brief that + uses `### Context` as read-only grounding while proving the request payload + still flows through gateway `Maintains` -> subscriber `Requires`. ### Fan-in, diamonds & failure isolation diff --git a/skills/open-prose/examples/context-boundary/README.md b/skills/open-prose/examples/context-boundary/README.md new file mode 100644 index 00000000..dd5d6b00 --- /dev/null +++ b/skills/open-prose/examples/context-boundary/README.md @@ -0,0 +1,104 @@ +# context-boundary + +**The standing goal:** maintain a bounded request brief while keeping runtime +payloads out of `### Context`. + +This example exists to show the intended shape of `### Context`: it gives the +render read-only grounding, but the actual request still enters through a +gateway, is published as maintained truth, and wakes the downstream +responsibility through `### Requires`. + +## The DAG + +```text +request-inbox (gateway, external-driven) + maintains: request + triggered with --data + | + | facet: request + v +context-brief (responsibility, input-driven) + requires: request + context: read-only guidance for interpreting the request + maintains: brief +``` + +## What `### Context` does here + +`context-brief` uses `### Context` to say: + +- where the declared upstream truth is expected to come from; +- that the render must not invent request ids, source revisions, or user goals; +- that the staged request must be read through upstream world-model tools. + +The request itself is **not** in Context. It enters through +`reactor trigger request-inbox --data ...`, is folded by the gateway into the +`request` facet, and then wakes `context-brief`. + +## Deterministic test path + +This example is covered by the Reactor CLI offline tests. From the repository +root: + +```bash +REACTOR_OFFLINE=1 pnpm --filter @openprose/reactor-cli build:test +REACTOR_OFFLINE=1 node --test packages/reactor-cli/dist-test/__tests__/run.test.js +REACTOR_OFFLINE=1 node --test packages/reactor-cli/dist-test/__tests__/connectors.test.js +``` + +The positive end-to-end case is: + +```text +stages --data, propagates request truth, and renders a Context-grounded subscriber +``` + +It exercises this path: + +```text +src/*.prose.md + -> compile with fake structured providers + -> persisted compiled IR / contract views + -> reactor trigger request-inbox --data + -> gateway ingress staging + -> request-inbox publishes request truth + -> context-brief wakes from the moved request fingerprint + -> context-brief reads upstream truth by reference + -> context-brief publishes state/brief.md + -> ingress, gateway, and subscriber receipts chain-verify +``` + +The main negative boundary case is: + +```text +the ONE-SHOT `reactor trigger --data` path fails closed (B3) +``` + +That negative case proves `--data` cannot be used as a hidden payload lane into a +non-gateway responsibility. + +## Manual Reactor run + +If you have Reactor configured with a model provider, run: + +```bash +cd skills/open-prose/examples/context-boundary +reactor doctor +reactor compile +reactor topology +reactor trigger request-inbox --data '{"id":"ctx-demo-001","source_revision":"manual","goal":"Summarize what the Context boundary allows."}' +reactor receipts +``` + +You should see: + +- topology with `request-inbox` as the external gateway and `context-brief` as an + input-driven subscriber; +- trigger output where `request-inbox` and `context-brief` render; +- at least three receipts: the staged ingress arrival, the gateway render, and + the subscriber render; +- `context-brief` waking from input, not from an external payload; +- a published brief that names the request id and source revision from the + gateway-maintained request truth. + +The exact prose in the brief may vary by model. The boundary that should not +vary is the data path: payload -> gateway `Maintains` -> subscriber `Requires`. diff --git a/skills/open-prose/examples/context-boundary/src/context-brief.prose.md b/skills/open-prose/examples/context-boundary/src/context-brief.prose.md new file mode 100644 index 00000000..dbee37b8 --- /dev/null +++ b/skills/open-prose/examples/context-boundary/src/context-brief.prose.md @@ -0,0 +1,40 @@ +--- +name: context-brief +kind: responsibility +version: 0.15.0 +--- + +# Context Brief + +Maintain a short brief for the latest request while keeping the distinction +between declared context and runtime truth visible. + +### Requires + +- `request`: the staged request payload maintained by `request-inbox`. + +### Context + +- Source of truth: read the triggered request payload from `request-inbox` on facet `request`. +- Treat this section as read-only grounding; do not invent a request id, source revision, or user goal. +- Context can explain how to interpret the request, but it does not satisfy the `request` requirement. + +### Maintains + +- `brief`: a concise, auditable brief for the current request. + +#### brief + +Material: request id, source revision, request goal, and a short statement of +which declared truth was used. + +### Continuity + +- input-driven: render when the `request` facet from `request-inbox` moves. + +### Execution + +- Use `wm_list_upstream` to confirm the available upstream producer/facet pairs. +- Use `wm_read_upstream` to read `request-inbox` on facet `request`. +- Write `state/brief.md` with the request id, source revision, goal, and a note + that the brief used declared Context plus upstream request truth. diff --git a/skills/open-prose/examples/context-boundary/src/request-inbox.prose.md b/skills/open-prose/examples/context-boundary/src/request-inbox.prose.md new file mode 100644 index 00000000..af58fb2b --- /dev/null +++ b/skills/open-prose/examples/context-boundary/src/request-inbox.prose.md @@ -0,0 +1,43 @@ +--- +name: request-inbox +kind: gateway +version: 0.15.0 +--- + +# Request Inbox + +External entry point for one request payload. This gateway is intentionally +boring: it receives a request body, normalizes it, and publishes the latest +request as maintained truth. + +### Continuity + +- external-driven: a `reactor trigger request-inbox --data ...` call wakes this + gateway. It has no upstream `### Requires`. + +### Receives + +- `reactor trigger request-inbox --data ` + +### Maintains + +- `request`: the latest staged request payload, normalized as structured truth. + +#### request + +Material: request id, source revision, goal, and any caller-supplied request +fields needed by downstream responsibilities. + +### Payload + +The trigger body should include: + +- `id`: stable request id. +- `source_revision`: source or prompt revision that produced the request. +- `goal`: the user's requested outcome. + +### Execution + +- Read the staged trigger payload from the gateway ingress inbox. +- Normalize it as `state/request.json`. +- Do not write a brief here; this node only maintains the request truth. diff --git a/skills/open-prose/reactor.md b/skills/open-prose/reactor.md index 3bd38c03..7c688975 100644 --- a/skills/open-prose/reactor.md +++ b/skills/open-prose/reactor.md @@ -101,6 +101,11 @@ devtools `--describe`) the agent may always run directly to validate its output. helpers for expensive sub-steps. Apply the anti-pattern checklist: no "loop until done", no volatile fields in `### Maintains`, declare `valid_until` in `### Continuity`, facet the truth so an unrelated change wakes nobody. + If a downstream node needs a trigger body, webhook body, file event, or any + other runtime payload, model that payload as gateway-maintained truth and add + a `### Requires` facet on the downstream responsibility. Do not put runtime + payloads in `### Context`; Context is read-only grounding and does not create + the Forme edge that wakes the subscriber. 5. **Author `reactor.yml`.** State dir, model block, sandbox, and the gateway connector (see *Configuration* below). **Ask the user for provider/model** here @@ -147,8 +152,8 @@ model: compile_model: google/gemini-3.5-flash temperature: 0 # optional — delete the line to send no temperature max_turns: 200 - # reasoning_effort: none # reasoning models (gpt-5.x, o-series) reject an - # explicit temperature unless effort is none + # reasoning_effort: none # reasoning-class models (GPT-5/o-series/Claude 4) + # often reject explicit temperature unless effort is none sandbox: mode: none # none (default, bounded shell) | docker (network-disabled container) @@ -168,6 +173,12 @@ reactors: [] # optional: a multi-reactor host Global flags `--state-dir`, `--project`, `--json`, `--offline` override the file on every command. +Run `reactor doctor --json` before live `compile`/`run`. Doctor is keyless, and +reports model compatibility warnings for known provider failure classes. For +GPT-5/o-series/Claude 4-class models through OpenRouter-compatible endpoints, +prefer deleting `temperature:` unless you deliberately set `reasoning_effort: +none`. + **Provider/model — ask, don't assume.** The scaffold default is OpenRouter + `google/gemini-3.5-flash`. When generating `reactor.yml`, ask the user which provider/model to use; suggest **OpenRouter** as the safe default and note that @@ -210,6 +221,16 @@ note (never crashes). `reactor doctor` reports Docker availability under | `GET /cost` | Cost rollup by `surprise_cause` | | `POST /trigger/` | Wake `` with an optional JSON body as an external arrival; returns the disposition | +When using `POST /trigger/` or +`reactor trigger --data ...`, the payload is staged into that gateway's +ingress truth. A downstream subscriber sees it only if the gateway +`### Maintains` a facet and the subscriber `### Requires` that facet. A bare +trigger of a non-gateway node can wake the node, but it is not the payload +delivery pattern. Verify the result with `reactor topology`, +`reactor inspect `, `reactor trace `, and +`reactor receipts verify`; a successful payload delivery should show the ingress +receipt, the gateway receipt, and the subscriber receipt. + > **⚠ No auth in v1.** `POST /trigger/` is unauthenticated and can cause > model spend. The default `127.0.0.1` bind is loopback-only; expose it > (`--host 0.0.0.0`) only behind a proxy that adds auth + rate-limiting. Treat diff --git a/tests/open-prose/examples/intelligent-react-examples-corpus.test.ts b/tests/open-prose/examples/intelligent-react-examples-corpus.test.ts index f2913fc4..824bff82 100644 --- a/tests/open-prose/examples/intelligent-react-examples-corpus.test.ts +++ b/tests/open-prose/examples/intelligent-react-examples-corpus.test.ts @@ -1,5 +1,5 @@ // Doc-conformance test for the SHIPPED Intelligent-React learning examples: the -// 13 reactor-substrate examples authored to the validity contract and wired into +// reactor-substrate examples authored to the validity contract and wired into // the offline gate alongside their ledger-replay tests under // tests/open-prose/examples/. // @@ -40,6 +40,7 @@ const OWNED_EXAMPLES = [ // green, gated ledger-replay tests. "agent-observatory", "basic-unit-suite", + "context-boundary", "forme-fixpoint", "github-star-enricher", "implementation-pipeline",