From 1cd890d871211a92ba142745c88f0915fd097d8a Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 21 Aug 2026 00:21:53 +0200 Subject: [PATCH] feat: add versioned execution profiles --- AGENTS.md | 11 +- INTENT.md | 86 ++-- README.md | 58 ++- SCOPE.md | 51 ++- WORK-RECORDS.md | 10 + docs/channel-contract.md | 76 +--- .../GLAS-WP-0004-live-proof-2026-08-20.md | 62 +++ docs/execution-profiles.md | 87 ++++ docs/harness-contract.md | 169 ++++---- docs/intakes/residuals.md | 38 ++ examples/execution-request.json | 21 + profiles/harness.agent-dev-local.yaml | 18 +- .../harness.agent-dev-openweights-local.yaml | 25 ++ profiles/harness.agent-dev.yaml | 18 +- pyproject.toml | 4 + registry/README.md | 12 + registry/reins/rein-aharness.yaml | 7 +- registry/reins/rein-openweights.yaml | 6 +- src/glas_harness/channels/base.py | 2 +- src/glas_harness/channels/cli_channel.py | 2 +- src/glas_harness/cli.py | 63 ++- src/glas_harness/contract.py | 213 ++++++++-- src/glas_harness/gateway.py | 380 +++++++++++++----- src/glas_harness/profiles.py | 222 ++++++++++ src/glas_harness/reins/_shared.py | 16 + src/glas_harness/reins/rein_aharness.py | 78 +++- src/glas_harness/reins/rein_openweights.py | 77 +++- tests/test_cli.py | 21 + tests/test_cli_channel.py | 4 +- tests/test_gateway.py | 203 ++++++---- tests/test_profiles.py | 164 ++++++++ tests/test_rein_aharness.py | 20 +- tests/test_rein_openweights.py | 18 +- ...sioned-execution-constellation-profiles.md | 316 +++++++++++++++ 34 files changed, 2087 insertions(+), 471 deletions(-) create mode 100644 docs/evidence/GLAS-WP-0004-live-proof-2026-08-20.md create mode 100644 docs/execution-profiles.md create mode 100644 docs/intakes/residuals.md create mode 100644 examples/execution-request.json create mode 100644 profiles/harness.agent-dev-openweights-local.yaml create mode 100644 src/glas_harness/profiles.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_profiles.py create mode 100644 workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md diff --git a/AGENTS.md b/AGENTS.md index db625a5..56543fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,15 +14,18 @@ ## Developer Workflow ```bash -# Install (editable, pulls in the sand-boxer sibling checkout) +# Install core + sandbox and the concrete reins enabled on this host python3 -m venv .venv && source .venv/bin/activate -pip install -e . -e ../sand-boxer pytest +pip install -e . -e ../sand-boxer -e '../rein-aharness[llm]' -e ../rein-openweights pytest # Test python3 -m pytest tests/ -q -# Run — resolves a sand-boxer sandbox, runs one task through a rein, verifies -glas-harness run --sandbox-profile profile.bwrap-local \ +# Validate the executable profile/rein catalog +glas-harness profiles + +# Run — resolves the selected constellation, executes, verifies, and tears down +glas-harness run --harness-profile harness.agent-dev-local@1.0.0 \ --repo --title "..." --description "..." ``` diff --git a/INTENT.md b/INTENT.md index 230dd44..65d32f5 100644 --- a/INTENT.md +++ b/INTENT.md @@ -1,7 +1,7 @@ --- domain: infotech repo: glas-harness -updated: "2026-06-22" +updated: "2026-08-20" --- # INTENT @@ -9,8 +9,8 @@ updated: "2026-06-22" > glas-harness is the Coulomb **meta-framework for agent harnesses** — a unified > API and extension platform for running agentic assistants with tools, memory, > channels, and subagent delegation, while **consuming** sand-boxer for isolated -> execution. This file is preliminary; refine as the harness boundary is -> implemented. +> execution. The implemented nucleus is a versioned, profile-driven execution +> router over concrete reins. --- @@ -36,7 +36,7 @@ Coulomb splits that stack deliberately: | **Code generation** — specs, generation, PR output | **snuggle-inventor** | glas-harness exists to be the harness side of that split: **one consistent way to -run agents**, with extensions for channels and tool backends, and a single +execute assigned agent work**, with extensions for channels and tool backends, and a single integration path to sand-boxer when tools need isolation. sand-boxer is OpenRouter for sandboxes. glas-harness is the parallel **harness @@ -59,8 +59,8 @@ It answers: 2. **What can the agent invoke?** Tool catalog, policies, elevated escape hatches. 3. **What does the agent remember?** Memory, skills, identity documents, cron. 4. **Where does the user interact?** Channel extensions (CLI, chat, email, …). -5. **When do tools run in isolation?** Sandbox policy (`mode`, `scope`, - `workspaceAccess`) — fulfilled by **sand-boxer**, not implemented here. +5. **When do tools run in isolation?** A named sand-boxer profile selected by + the Glas profile — fulfilled by **sand-boxer**, not implemented here. 6. **How are subagents delegated?** Isolated sub-sessions with bounded context. 7. **What happened?** Session and tool audit events to State Hub. @@ -68,6 +68,31 @@ It must **not** become the sandbox provisioner, the e2e validator, the code generator, the scheduler, work-state authority, tunnel/CA owner, or production service host on Railiance01. +## Relationship to leadership and workforce management + +Leadership and workforce management govern responsibility: roles, duties, +goals, assignments, resource envelopes, escalation, and who may initiate work. +Glas governs execution after that decision. Its request can carry opaque +`assignment_ref`, `role_ref`, `duty_ref`, `goal_refs`, and +`resource_envelope_refs` so evidence remains attributable without duplicating +the organizational records. + +This separation is intentional: + +```text +leadership / workforce authority + -> authorized assignment + organizational references + -> Glas ExecutionRequest + explicit harness profile + -> resolved rein/model/sandbox/tool-policy constellation + -> normalized evidence linked back to the assignment +``` + +Glas may refuse an unavailable or incompatible execution profile. It does not +choose a leader, allocate a worker, reinterpret a duty, compile a role into a +blueprint, or decide when work should run. Those decisions remain inspectable +in their owning system while Glas supplies a stable, replaceable execution +boundary. + --- ## Coulomb sibling boundaries @@ -148,7 +173,11 @@ glas-harness is a **meta-framework** with four pillars (preliminary): ### 1. Unified harness API -One surface for session lifecycle across channel and automation consumers: +One versioned surface for session lifecycle across channel and automation +consumers. The implemented gateway accepts an explicit `ExecutionRequest`, +resolves a harness profile, executes one bounded rein session, and returns +`ExecutionEvidence`. Longer-lived sessions and subagent join semantics remain +future work. - Start / resume / end sessions; subagent spawn and join - Tool dispatch with policy checks and audit metadata @@ -160,14 +189,18 @@ human operators via CLI. ### 2. Harness profile catalog -Named, versioned **harness profiles** (distinct from sand-boxer sandbox profiles): +Named, versioned **harness profiles** (distinct from sand-boxer sandbox profiles) +are runtime inputs, not documentation. They currently bind: -- Default toolset and tool policy -- Sandbox policy defaults (`mode`, `scope`, `workspaceAccess`) — OpenClaw-aligned -- Memory and skills layout conventions -- Channel allowlist -- Model routing hints (consumer of `llm-connect`, not owner) -- Registered in `registry/` via reuse-surface +- concrete rein and required capabilities; +- sand-boxer profile; +- tool profile; +- model provider, route, class, and explicit model identifier; +- token, timeout, and turn limits; and +- credential-route references without credential values. + +Resolution is deterministic and fail-closed. Governed execution has no implicit +rein, model, sandbox, or tool policy. Example pairing: @@ -268,11 +301,11 @@ for integration design only. --- -## Sandbox consumption contract (preliminary) +## Sandbox consumption contract When harness policy requires isolation, glas-harness: -1. Resolves sandbox profile id (from harness profile or session override) +1. Resolves the sandbox profile id from the versioned harness profile 2. Calls sand-boxer `create` with `consumer: { harness: glas-harness, session_id, actor }` 3. Stores `sandbox_id` and reachability descriptor on the session 4. Routes `exec`, `read`, `write`, `edit`, and related tools through that handle @@ -280,7 +313,7 @@ When harness policy requires isolation, glas-harness: Harness owns **tool semantics**; sand-boxer owns **environment lifecycle**. -Open questions (for first workplan): +Open integration questions: - Does glas-harness proxy exec or delegate SSH/tunnel to the agent client? - How are mirror vs remote-canonical workspace modes exposed to tool implementations? @@ -290,15 +323,16 @@ T08 lands. --- -## Near-term outcomes (preliminary) +## Implemented nucleus and next outcomes -1. **This charter** — `INTENT.md` aligned with sand-boxer sibling boundaries -2. **Harness profile schema sketch** — distinct from sand-boxer profile schema -3. **sand-boxer integration doc** — consumer contract (may start in sand-boxer repo) -4. **First harness profile** — `harness.agent-dev` paired with `profile.agent-dev` -5. **CLI gateway stub** — minimal session + local tools (no channels yet) -6. **Registry entry** — e.g. `capability.platform.agent-harness` -7. **State Hub session events** — tool audit envelope +Implemented: strict contract version `1.0`, executable profile catalog, +rein-registry validation, explicit CLI/channel selection, sand-boxer lifecycle, +two concrete rein constellations, and normalized State Hub evidence. + +Next outcomes should be driven by real consumers: an additional channel, +memory/skills integration, bounded subagent delegation, and an organizational +assignment adapter. None should weaken explicit profile selection or move +workforce authority into Glas. --- @@ -313,4 +347,4 @@ A mature glas-harness is Coulomb's **default agent runtime**: - Extensions add channels and tool backends without forking core gateway logic The harness thinks and coordinates. sand-boxer establishes the box. wise-validator -proves correctness. snuggle-inventor invents code. **glas-harness runs the agent.** \ No newline at end of file +proves correctness. snuggle-inventor invents code. **glas-harness runs the agent.** diff --git a/README.md b/README.md index f399e1b..c9d3e78 100644 --- a/README.md +++ b/README.md @@ -1 +1,57 @@ -Agent harness platform and meta framework. \ No newline at end of file +# glas-harness + +`glas-harness` is the primary execution-layer abstraction for governed agent +work. Consumers select a versioned Glas profile; Glas resolves the concrete +rein, model route, sandbox profile, tool policy, and limits, runs the outer +lifecycle, and returns one normalized evidence envelope. + +The consumer contract does not name a Python rein class or provider CLI: + +```text +assignment / channel / activity + -> ExecutionRequest(harness_profile_ref=...) + -> Glas profile resolution + -> rein + model + sandbox + tool policy + -> normalized ExecutionEvidence +``` + +## Profiles + +List and validate every committed executable profile: + +```bash +glas-harness profiles +glas-harness profiles --json +``` + +Current constellations include: + +- `harness.agent-dev-local@1.0.0`: `rein-aharness`, Claude Code model route, + local bwrap sandbox. +- `harness.agent-dev-openweights-local@1.0.0`: `rein-openweights`, OpenRouter + open-weight route, local bwrap sandbox. +- `harness.agent-dev@1.0.0`: `rein-aharness`, remote agent-dev sandbox. + +Run a task through an explicit profile: + +```bash +glas-harness run \ + --harness-profile harness.agent-dev-local@1.0.0 \ + --repo /absolute/path/to/repo \ + --title "Bounded change" \ + --description "Make the requested change and commit it" +``` + +There is no governed default rein, model, sandbox, or tool profile. Unknown, +disabled, incompatible, and ambiguous selections are refused before sandbox +creation. + +See [SCOPE.md](SCOPE.md), [INTENT.md](INTENT.md), +[docs/execution-profiles.md](docs/execution-profiles.md), and +[docs/harness-contract.md](docs/harness-contract.md). + +## Development + +```bash +uv run pytest -q +``` diff --git a/SCOPE.md b/SCOPE.md index 4c2ef22..8e9a996 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -2,21 +2,31 @@ ## One-liner -Meta-framework routing between concrete agent-harness backends ("reins"), -consuming sand-boxer for isolation. See `docs/adr/ADR-001-rein-harness-family.md`. +Primary, profile-driven execution layer routing governed work between concrete +agent-harness backends ("reins") while consuming sand-boxer for isolation. See +`docs/adr/ADR-001-rein-harness-family.md`. ## Core Idea glas-harness owns the harness contract (`docs/harness-contract.md`: `start_session`/`dispatch_tool`/`end_session`) and the outer loop — profile -resolution, sandbox request/teardown via sand-boxer, actor attribution. A -rein owns its own inner agentic loop and is invoked through that contract. +resolution, rein/model/tool-policy selection, sandbox request/teardown via +sand-boxer, actor attribution, and normalized evidence. A rein owns its own +inner agentic loop and is invoked through that contract. + +Workforce and leadership systems decide *who is responsible for what* and pass +assignment, role, duty, goal, and resource-envelope references. Glas decides +*how that already-authorized assignment executes*. It does not assign workers, +elect leaders, decompose organizational goals, or schedule work. ## In Scope -- The `Rein` ABC and `SandboxHandle`/`ToolCall`/`ToolResult` types +- Versioned strict execution request, profile, lifecycle, result, and evidence + models plus the `Rein` ABC (`src/glas_harness/contract.py`) -- The gateway that resolves a harness profile, requests a sand-boxer sandbox, +- Deterministic harness-profile and rein-registry validation/resolution + (`src/glas_harness/profiles.py`) +- The gateway that resolves an explicit harness profile, requests a sandbox, runs one task through a rein, verifies, tears down (`src/glas_harness/gateway.py`) - Rein adapters that shell out to each rein's own CLI @@ -24,29 +34,42 @@ rein owns its own inner agentic loop and is invoked through that contract. `rein_openweights.py` - The rein registry (`registry/reins/`) and harness profile catalog (`profiles/`) +- Compact, non-secret execution evidence and attribution references for State + Hub and other audit consumers ## Out of Scope - Sandbox provisioning, profiles, placement — owned by `sand-boxer` - A rein's own agentic loop, tool execution, credential acquisition — owned by the rein itself (`rein-aharness`, `rein-openweights`, ...) -- Scheduling/task sourcing — stays rein-local +- Leadership, workforce allocation, role/duty/goal definitions, assignment + authority, scheduling, and task sourcing — owned by their domain systems and + passed to Glas as references +- Scheduling/blueprint sourcing inside the execution layer — stays rein-local (`docs/adr/ADR-003-scheduling-and-blueprint-sourcing-stay-rein-local.md`) +- Provider/model catalog authority and inference APIs — owned by providers and + `llm-connect`; Glas carries the approved route and resolved identifier +- Secrets and credential brokering — credentials remain rein-local and profiles + contain route references only - e2e validation, code generation — owned by `wise-validator`, `snuggle-inventor` ## Current State -`GLAS-WP-0001` (harness contract + first two reins) is done. Both -`rein-aharness` and `rein-openweights` are live-proven through the gateway -against their real underlying providers (Claude Code CLI, OpenRouter). -Not yet built: channel bridges, memory/skills layer, glas-harness's own -State Hub reporting from the gateway (currently each rein reports its own -events). +The contract and first two reins are implemented. Profiles are executable, +versioned runtime inputs; CLI and gateway invocation require an explicit +profile; and both rein/model constellations are live-proven with real commits, +sandbox cleanup, and the same evidence shape. +The CLI channel and Glas-owned State Hub gateway reporting are implemented. + +Still outside the current prototype: additional real channel extensions, +memory/skills services, subagent delegation, requirements-based profile +selection, and workforce-management APIs. Those are not implicit capabilities +of the profile router. ## Getting Oriented - Start with: `INTENT.md`, `docs/adr/ADR-001-rein-harness-family.md` -- Contract: `docs/harness-contract.md` +- Contract: `docs/harness-contract.md`, `docs/execution-profiles.md` - Agent instructions: `AGENTS.md` - Workplans: `workplans/` diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 518b991..97899ee 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -12,6 +12,7 @@ | workplan | GLAS-WP-0001 | finished | — | workplans/GLAS-WP-0001-harness-router-foundation.md | | workplan | GLAS-WP-0002 | finished | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md | | workplan | GLAS-WP-0003 | finished | — | workplans/GLAS-WP-0003-first-channel-extension.md | +| workplan | GLAS-WP-0004 | finished | — | workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md | | task | GLAS-0001-T01 | done | — | workplans/GLAS-0001-statehub-bootstrap.md | | task | GLAS-0001-T02 | done | — | workplans/GLAS-0001-statehub-bootstrap.md | | task | GLAS-0001-T03 | done | — | workplans/GLAS-0001-statehub-bootstrap.md | @@ -29,3 +30,12 @@ | task | GLAS-WP-0003-T02 | done | — | workplans/GLAS-WP-0003-first-channel-extension.md | | task | GLAS-WP-0003-T03 | done | — | workplans/GLAS-WP-0003-first-channel-extension.md | | task | GLAS-WP-0003-T04 | done | — | workplans/GLAS-WP-0003-first-channel-extension.md | +| task | GLAS-WP-0004-T01 | done | — | workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md | +| task | GLAS-WP-0004-T02 | done | — | workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md | +| task | GLAS-WP-0004-T03 | done | — | workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md | +| task | GLAS-WP-0004-T04 | done | — | workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md | +| task | GLAS-WP-0004-T05 | done | — | workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md | +| task | GLAS-WP-0004-T06 | done | — | workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md | +| task | GLAS-WP-0004-T07 | done | — | workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md | +| task | GLAS-WP-0004-T08 | done | — | workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md | +| intake | GLAS-IN-0001 | done | blue | docs/intakes/residuals.md | diff --git a/docs/channel-contract.md b/docs/channel-contract.md index d4d8ec2..30f4d86 100644 --- a/docs/channel-contract.md +++ b/docs/channel-contract.md @@ -1,76 +1,34 @@ # Channel contract -How an external invocation reaches the gateway, per `GLAS-WP-0003`. -Mirrors `docs/harness-contract.md`'s `Rein` at a different layer: `Rein` -governs how a session runs once glas-harness has decided to run one; a -`Channel` governs how an external caller (CLI today; a future Slack -message, HTTP request, or scheduled trigger) gets translated into that -decision in the first place, and how the result gets translated back. - -## Interface +A channel translates an external invocation into profile-driven gateway input; +it does not select a rein class or execute a provider itself. ```python @dataclass class GatewayInvocation: - """Everything run_task_through_rein needs, channel-agnostic.""" - sandbox_profile: str + harness_profile: str repo: str title: str description: str actor: str = "agt" project: str = "glas-harness" report_to_hub: bool = True - - -class Channel(ABC): - """Base class for concrete invocation channels (CLI, Slack, HTTP, ...).""" - - @abstractmethod - def parse_invocation(self, raw: Any) -> GatewayInvocation: - """Turn a channel-specific invocation into a GatewayInvocation.""" - - @abstractmethod - def render_result(self, result: dict[str, Any]) -> Any: - """Turn a gateway result dict into whatever this channel returns.""" ``` -`src/glas_harness/channels/base.py`. A channel does not choose the rein -or run the gateway itself — `cli.py`'s `main()` still does that, calling -`parse_invocation`/`render_result` around the existing -`run_task_through_rein` call. Keeping channel construction and gateway -invocation separate (rather than folding gateway-calling into the -`Channel` ABC itself) is what lets `CLIChannel` be a pure -input/output mapper, easy to unit test without touching sand-boxer or a -rein at all. +`CLIChannel` is the current implementation. The CLI requires +`--harness-profile `; the old sandbox-only input is removed +because it could not identify the rein, model route, tool policy, or contract +revision. Channels may obtain a profile choice from their own approved +configuration, but must pass it explicitly and must surface resolution refusal +to their caller. -## CLIChannel — the first, and so far only, implementation +The channel renders the full direct `GatewayResult`. State Hub receives only +the compact `ExecutionEvidence` subset. -`src/glas_harness/channels/cli_channel.py`. Maps an argparse -`Namespace` to `GatewayInvocation` (one field each, `--no-hub` inverts -to `report_to_hub`) and renders a result dict as -`json.dumps(result, indent=2)` — exactly what `cli.py` did inline before -this existed. This was a refactor, not a behavior change: verified with -a real live run through `python3 -m glas_harness.cli run ...` producing -byte-identical output shape to the pre-refactor version, plus unit tests -for the mapping in isolation (`tests/test_cli_channel.py`). +## Adding a channel -## Adding a second channel (not done — pattern only) - -No second channel exists yet, and none should be built speculatively — -same discipline as ADR-002/ADR-004 (generalize from a real second -example, not in anticipation of one). If/when one is needed, e.g. a -Slack channel: - -1. Implement `Channel` in `src/glas_harness/channels/slack_channel.py`: - `parse_invocation` turns a Slack event payload into a - `GatewayInvocation` (message text → `title`/`description`, a - configured default `sandbox_profile`, the Slack user id as `actor` - or `project`); `render_result` turns the result dict into a Slack - message (success/failure, commit sha if present). -2. Whatever process receives Slack events constructs the channel and - calls `run_task_through_rein` the same way `cli.py` does — no change - needed to `gateway.py`, `contract.py`, or any rein. -3. If two real channels turn out to share invocation-parsing logic (e.g. - both need the same `sandbox_profile` defaulting rule), factor that - shared piece out then — not before, and not into the `Channel` ABC - itself unless every channel needs it. +Implement `parse_invocation` and `render_result`, then call `run_execution` +with an `ExecutionRequest`. Carry any assignment/role/duty/goal/resource +references supplied by the upstream workforce system. Do not duplicate profile +resolution, sandbox coordination, leadership decisions, or rein construction in +the channel. diff --git a/docs/evidence/GLAS-WP-0004-live-proof-2026-08-20.md b/docs/evidence/GLAS-WP-0004-live-proof-2026-08-20.md new file mode 100644 index 0000000..a795142 --- /dev/null +++ b/docs/evidence/GLAS-WP-0004-live-proof-2026-08-20.md @@ -0,0 +1,62 @@ +# GLAS-WP-0004 live profile evidence — 2026-08-20 + +Acceptance task for both profiles: change one `README.md` status value from +`pending` to `complete`, make no other content change, and commit it. Each run +used a separate temporary initialized repository and `profile.bwrap-local`. +No credential value, prompt transcript, or raw model output is recorded here. + +## `harness.agent-dev-local@1.0.0` + +- Result: succeeded. +- Contract: `1.0`. +- Rein: `rein-aharness@0.1.0`. +- Model route/resolved model: `claude-code-cli` / `claude-sonnet-4-6`. +- Tool profile: `green-commit-only`. +- Sandbox ID: `e61624cb`; local lifecycle store verified `destroyed`. +- Request ID: `96b2ec85-3823-4088-bcf4-a008c02e2354`. +- Commit: `795f8ef8b414f4470e812005f4103b8c5f126829`. +- Tool-event visibility: complete, 14 events. +- Reported execution time: 29.051 seconds. + +The first attempt also proved normalized dependency/startup failure and +sandbox cleanup: the acceptance environment lacked rein-aharness's optional +`llm-connect` install. After installing the documented sibling dependency, the +same profile and task succeeded. Production packaging must install the concrete +rein with its execution dependencies. + +## `harness.agent-dev-openweights-local@1.0.0` + +- Result: succeeded on the 2026-08-21 local-time recheck after credential repair. +- Contract: `1.0`. +- Rein: `rein-openweights@0.1.0`. +- Model route/resolved model: `rein-openweights-openrouter` / + `qwen/qwen-2.5-72b-instruct`. +- Tool profile: `green-commit-only`. +- Sandbox ID: `220482bc`; local lifecycle store verified `destroyed`. +- Request ID: `b783af7f-e728-4dee-9034-f1c2262aa9ea`. +- Commit: `b0600b25066731c6e1fc458409429f76a844f959`. +- Turns/tokens: 3 / 3,497 of the 60,000-token profile budget. +- Tool-event visibility: unavailable, reported explicitly rather than as an + empty claim of completeness. +- Reported execution time: 300.095 seconds. + +The recheck exposed two credential-selection facts without revealing a value: +an inherited `OPENROUTER_API_KEY` still contained the retired key and therefore +masked the repaired workload lane, while the AppRole files existed at the +documented standard directory but code required an environment variable to use +them. The successful run removed the stale ambient override and selected the +approved AppRole directory. `rein-openweights` now defaults to +`~/.local/rein-openweights/approle`; explicit environment credentials retain +development precedence and are documented as needing refresh/removal after +rotation. + +## Acceptance status + +Both explicit profiles resolved and dispatched distinct rein/model +constellations, completed the same semantic task with a real commit, returned +the common Glas evidence envelope, and destroyed their local sandboxes. No +alternative workload credential was borrowed. + +Rollback for consumers is to repin to the last approved Glas profile. The old +`--sandbox-profile` governed path and implicit `ReinAharness()` default are not +restored. diff --git a/docs/execution-profiles.md b/docs/execution-profiles.md new file mode 100644 index 0000000..0ddd45f --- /dev/null +++ b/docs/execution-profiles.md @@ -0,0 +1,87 @@ +# Execution profiles and workforce handoff + +Glas profiles are versioned execution constellations. They make the execution +layer replaceable without changing an upstream workforce assignment. + +```text +RoleAssignment / activity / channel + | organizational refs + bounded task + harness_profile_ref + v +ExecutionRequest + | deterministic profile validation + v +rein + model route + sandbox profile + tool profile + limits + | one outer lifecycle + v +ExecutionEvidence -> assignment/audit consumers +``` + +## Profile schema + +Each YAML document under `profiles/` declares: + +- stable `id`, semantic `version`, exact `contract_version`, and status; +- rein registry ID and required capabilities; +- sand-boxer `sandbox_profile`; +- rein-enforced `tool_profile`; +- model provider, explicit model identifier, model class, and route; +- positive token/timeout/turn limits when applicable; and +- credential route references and non-secret metadata. + +The catalog discovers files deterministically, forbids unknown schema fields, +rejects duplicate revisions, and checks the selected profile against +`registry/reins/*.yaml`. Unpinned lookup is accepted only when exactly one +enabled revision exists. Inline secrets and token-looking values are refused. + +Use `glas-harness profiles` as the catalog/packaging validation command. + +Catalog validation proves schema/compatibility, not host installation. An +execution host must install `glas-harness` with sand-boxer support and each rein +used by its enabled profiles. The Claude-backed rein also needs its documented +`rein-aharness[llm]`/sibling `llm-connect` adapter dependency. Missing runtime +dependencies surface as startup/execution failure evidence and still trigger +sandbox teardown. + +## Consumer request + +See `examples/execution-request.json`. Consumers know only a profile reference, +not `ReinAharness`, `ReinOpenWeights`, Claude Code arguments, or OpenRouter +client details. An assignment system may set the organizational references; a +channel may omit fields it does not own. + +If the requested profile, contract, rein, capability, resource envelope, or +credential route is unavailable, the caller must receive a refusal/failure. No +consumer may silently substitute a different rein or model constellation. + +The current exact-profile path does not interpret resource envelopes or choose +profiles from requirements. A future requirements resolver belongs at this +Glas boundary, must be deterministic and explainable, and must refuse ambiguity. + +## Two equivalent selections + +For the same task and organizational references, switch only: + +```text +harness.agent-dev-local@1.0.0 + -> rein-aharness / claude-sonnet-4-6 / profile.bwrap-local + +harness.agent-dev-openweights-local@1.0.0 + -> rein-openweights / qwen/qwen-2.5-72b-instruct / profile.bwrap-local +``` + +Both use `green-commit-only` and return `GatewayResult` / `ExecutionEvidence`. +This proves interchangeability at the Glas boundary; it does not claim the +models have identical quality, price, or provider behavior. + +## Ownership handoff + +- Agentic-resources/KaizenAgentic: leadership, role, duty, goal, resource + envelope, worker/agent instance, and assignment definitions. +- Activity-core or a channel: when work is invoked and the bounded task input. +- Glas: versioned execution selection, lifecycle, attribution, and evidence. +- Rein: inner loop, backend-specific policy enforcement and credentials. +- llm-connect/provider: inference/provider boundary and model capabilities. +- Sand-boxer: sandbox profiles, placement, provisioning, and teardown. + +Rollback is selection-based: repin a caller to its last approved Glas profile. +Do not bypass Glas with `--sandbox-profile` or add a hidden default rein. diff --git a/docs/harness-contract.md b/docs/harness-contract.md index 47ef56b..9f13ab4 100644 --- a/docs/harness-contract.md +++ b/docs/harness-contract.md @@ -1,124 +1,97 @@ # Harness contract -The interface a concrete harness backend ("rein") implements so glas-harness -can route to it, per `docs/adr/ADR-001-rein-harness-family.md`. Mirrors -sand-boxer's `SandboxExtension` ABC (`provision`/`wait_ready`/`teardown`) at -the harness level, one layer up: sand-boxer establishes *where* dangerous -work runs, this contract governs *how an agent session runs* on top of it. +Contract version: `1.0` (`glas_harness.contract.CONTRACT_VERSION`). -## Interface +This is the stable boundary between Glas, its consumers, and concrete harness +backends (reins). Glas owns profile resolution and the outer lifecycle; a rein +owns its inner agentic loop. Sand-boxer owns environment provisioning. + +## Consumer boundary + +`ExecutionRequest` requires an explicit `harness_profile_ref`, repository, +title, and description. It may carry correlation and organizational references +(`assignment_ref`, `role_ref`, `duty_ref`, `goal_refs`, and +`resource_envelope_refs`). Those references link execution evidence to +leadership/workforce records; they do not transfer organizational authority to +Glas. + +The gateway returns `GatewayResult` with: + +- `ok`, derived from normalized outcome; +- `evidence`, safe for compact State Hub reporting; and +- direct-caller-only `tool_output` and `tool_error`. + +Raw prompts, model output, tool output, and credential material are excluded +from the State Hub detail. + +## Rein lifecycle ```python class Rein(ABC): - """Base class for concrete harness backends (reins).""" - - @abstractmethod def start_session( self, profile: HarnessProfile, inputs: dict[str, str], sandbox: SandboxHandle - ) -> dict[str, str]: - """Begin an agent session bound to a sandbox. Returns a session handle.""" + ) -> dict[str, str]: ... - @abstractmethod def dispatch_tool( self, session: dict[str, str], tool_call: ToolCall - ) -> ToolResult: - """Run one tool call under the session's policy. Returns result + audit envelope.""" + ) -> ToolResult: ... - @abstractmethod - def end_session(self, session: dict[str, str]) -> dict[str, str]: - """Close the session. Returns a summary (commit sha, tokens, duration, outcome).""" + def end_session(self, session: dict[str, str]) -> ExecutionSummary: ... ``` -- `start_session` resolves the rein's own setup (persona/blueprint loading, - credential acquisition) against the sandbox handle glas-harness already - obtained from sand-boxer — the rein never provisions or tears down a - sandbox itself. -- `dispatch_tool` enforces the tool-profile allow-list glas-harness resolved - (the same allow-list concept rein-aharness's `profiles.py` already - implements today, just invoked through the contract instead of only the - rein's own CLI). -- `end_session` is where the existing "commit exists = success" signal - (rein-aharness) or an equivalent per-rein success criterion is reported - back to glas-harness for State Hub posting. glas-harness posts the audit - event; the rein returns the facts. - -## Session lifecycle (who calls what) - -``` -glas-harness rein sand-boxer -──────────── ──── ────────── -resolve harness.* profile -request sandbox ──────────────────────────────▶ create() -receive sandbox handle ◀────────────────────────────── -start_session(profile, in, sbx) ─▶ acquire creds, load persona - ◀─ session handle -dispatch_tool(session, call) ─▶ run under allow-list - ◀─ result + audit envelope - (repeat dispatch_tool ...) -end_session(session) ─▶ verify success, summarize - ◀─ summary -post State Hub event -request sandbox teardown ──────────────────────────────▶ destroy() +```text +Glas rein sand-boxer +---- ---- ---------- +resolve profile + rein descriptor +create sandbox ------------------------------------------> create +start_session(profile, inputs, handle) -> rein setup +dispatch_tool(session, call) -> inner loop +end_session(session) -> normalized facts +destroy sandbox -----------------------------------------> destroy +publish compact ExecutionEvidence ``` -glas-harness owns the *outer* loop (profile resolution, sandbox -request/teardown, State Hub reporting, actor attribution). The rein owns -the *inner* loop (its own agentic reasoning, whatever that looks like — -Claude Code CLI subprocess for `rein-aharness`, a direct OpenRouter -tool-use loop for `rein-openweights`). +There is no production default rein. Direct `Rein` injection remains a narrow +library/test seam but still requires a valid profile so profile, sandbox, +model, tool policy, and evidence are explicit. -## Actor attribution +## Stability and extension rules -Every `dispatch_tool` call and lifecycle transition carries an actor -(`adm`/`agt`/`atm`) per glas-harness INTENT.md's audit requirement. -glas-harness stamps this at dispatch time; reins do not need their own -actor model. +All contract models use Pydantic with unknown fields forbidden. Stable `1.0` +fields are the declared fields in `src/glas_harness/contract.py`. Optional +measurements such as tokens, duration, resolved model, commit, and artifacts +remain `null` when unknown; unsupported tool-event visibility is +`unavailable`, not an empty claim of completeness. -## Composable reins as middleware (deferred — sketch only, no code) +Rein-specific, non-secret additions belong only in declared `metadata` fields. +Adding a required field, changing a field meaning/type, or removing a field +requires a new contract version. Adding an optional field may remain compatible +only when old consumers can safely ignore it and every strict boundary model is +updated together. A profile and rein descriptor must both declare the exact +supported contract version. -`docs/adr/ADR-002-credential-brokering-and-composable-reins.md` part 2 -raised whether monitoring/evaluation/optimization should become their -own composable reins — middleware that wraps or observes a base rein's -`dispatch_tool` calls, rather than each being a complete alternative -harness backend the way `rein-aharness`/`rein-openweights` are. Resolved -in `docs/adr/ADR-004-composable-reins-stay-deferred.md`: **still -deferred, sketch recorded, no code written.** The two observability -additions since (rein-aharness's per-tool-call audit stream, -`glas-harness`'s own gateway hub event) both turned out to be simpler as -direct implementations — neither needed a separate wrapping rein — so -there still isn't a second real capability wanting this shape, only the -original one (llm-connect's optional Functional-layer modules, -untouched). +Sensitive values are never contract extensions. Profiles may contain +credential-route references, while credential resolution and provider setup +remain rein-local under ADR-002. -If a real second candidate appears, the shape would be: +## Failure semantics -```python -class Middleware(ABC): - """Wraps another Rein's dispatch_tool, does not replace start_session/end_session.""" +The gateway returns evidence for every normal refusal/failure path: - def __init__(self, inner: Rein) -> None: - self.inner = inner +- `resolution`: unknown, disabled, ambiguous, incompatible, or unsafe profile; +- `sandbox_create`; +- `session_start`; +- `execution` (including a rein-declared unsuccessful result); +- `session_end`; and +- `teardown`. - @abstractmethod - def dispatch_tool(self, session: dict[str, str], tool_call: ToolCall) -> ToolResult: - """Call self.inner.dispatch_tool(...), observe/transform, return the result.""" +Profile resolution happens before sandbox creation. Once a sandbox exists, +teardown is attempted on every path. `refused` means governed execution did not +proceed; `failed` means an attempted lifecycle did not complete successfully. - def start_session(self, profile, inputs, sandbox) -> dict[str, str]: - return self.inner.start_session(profile, inputs, sandbox) +## Deliberate non-goals - def end_session(self, session) -> dict[str, str]: - return self.inner.end_session(session) -``` - -A chain of `Middleware` wrapping a base `Rein` still satisfies the `Rein` -ABC itself (composition, not a parallel type), so glas-harness's gateway -would not need to change to consume one — this is deliberately *not* a -speculative gateway-side change, just a documented shape to reach for -if/when a second candidate shows up. - -## Open questions this contract does not resolve yet - -- The exact `SandboxHandle`/`ToolCall`/`ToolResult` field shapes — these - have stabilized in practice (both reins implement them identically) - but are not yet declared frozen/versioned the way sand-boxer's models - are. +The contract does not schedule work, source blueprints, allocate workers, +resolve leadership, broker credentials, select an unapproved model by price, or +standardize rein internals. Composable rein middleware remains deferred by +ADR-004 until a second concrete need exists. diff --git a/docs/intakes/residuals.md b/docs/intakes/residuals.md new file mode 100644 index 0000000..fba0a71 --- /dev/null +++ b/docs/intakes/residuals.md @@ -0,0 +1,38 @@ +# glas-harness residual intakes + +## GLAS-IN-0001 — Restore open-weight live-profile credential lane + +```yaml +id: GLAS-IN-0001 +kind: intake +title: "Restore the rein-openweights OpenRouter lane and close the dual-profile live proof" +lane: blue +status: done +priority: high +owner: ops-mason +repo: glas-harness +origin: residual +origin_ref: GLAS-WP-0004 +description: | + On 2026-08-20, harness.agent-dev-openweights-local@1.0.0 resolved and + dispatched the same bounded fixture used by the successful rein-aharness + profile. The existing rein-local credential reached OpenRouter but the + provider returned HTTP 401 User not found before a model turn. Glas returned + normalized, redacted failure evidence and tore down sandbox bc9dec6a. + + Route catalog id: rein-openweights-openrouter-approle. Credential values must + not be placed in this record, State Hub, Git, or chat. + + The owning credential operator should rotate or repair that exact workload + lane, verify field presence without disclosure, and notify glas-harness. + Then rerun the bounded acceptance task through + harness.agent-dev-openweights-local@1.0.0, record a real commit plus cleanup + evidence, and mark GLAS-WP-0004-T07 done. Do not borrow llm-connect's + separate OpenRouter credential. + + Completed 2026-08-21. The repaired lane authenticated after removing a stale + inherited OPENROUTER_API_KEY override and selecting the standard AppRole + directory. The profile completed in 3 turns, spent 3,497 tokens, produced + commit b0600b25066731c6e1fc458409429f76a844f959, and sandbox 220482bc was + verified destroyed. The AppRole directory is now the code default. +``` diff --git a/examples/execution-request.json b/examples/execution-request.json new file mode 100644 index 0000000..8d82592 --- /dev/null +++ b/examples/execution-request.json @@ -0,0 +1,21 @@ +{ + "harness_profile_ref": "harness.agent-dev-local@1.0.0", + "repo": "/absolute/path/to/target-repo", + "title": "Implement the bounded assignment", + "description": "Apply the approved change, run focused checks, and commit the result.", + "actor": "agt", + "project": "example-domain", + "request_id": "execution-018f", + "correlation_id": "activity-42", + "assignment_ref": "role-assignment:agent-7:42", + "role_ref": "role:maintainer@3", + "duty_ref": "duty:bounded-change@2", + "goal_refs": [ + "goal:reliable-delivery@5" + ], + "resource_envelope_refs": [ + "resource-envelope:standard-coding@1" + ], + "expected_output": "A verified commit and normalized execution evidence", + "report_to_hub": true +} diff --git a/profiles/harness.agent-dev-local.yaml b/profiles/harness.agent-dev-local.yaml index 597aaeb..a4f0f95 100644 --- a/profiles/harness.agent-dev-local.yaml +++ b/profiles/harness.agent-dev-local.yaml @@ -1,10 +1,26 @@ id: harness.agent-dev-local version: "1.0.0" -rein: rein-aharness +contract_version: "1.0" +status: enabled +rein: + id: rein-aharness + required_capabilities: + session_style: unattended + model_class: frontier sandbox_profile: profile.bwrap-local tool_profile: green-commit-only +model: + provider: anthropic + model: claude-sonnet-4-6 + model_class: frontier + route: claude-code-cli +limits: + budget_tokens: 60000 + timeout_seconds: 900 +credential_route_refs: [] metadata: latency_class: fast + stream_tool_events: true # Pairs with sand-boxer's profile.bwrap-local (ext.bwrap, same-host, # no SSH hop) — for CI/local dev where the compose-ssh remote-host # round trip is unnecessary overhead. diff --git a/profiles/harness.agent-dev-openweights-local.yaml b/profiles/harness.agent-dev-openweights-local.yaml new file mode 100644 index 0000000..e919da7 --- /dev/null +++ b/profiles/harness.agent-dev-openweights-local.yaml @@ -0,0 +1,25 @@ +id: harness.agent-dev-openweights-local +version: "1.0.0" +contract_version: "1.0" +status: enabled +rein: + id: rein-openweights + required_capabilities: + session_style: unattended + model_class: open-weight +sandbox_profile: profile.bwrap-local +tool_profile: green-commit-only +model: + provider: openrouter + model: qwen/qwen-2.5-72b-instruct + model_class: open-weight + route: rein-openweights-openrouter +limits: + budget_tokens: 60000 + timeout_seconds: 900 + max_turns: 20 +credential_route_refs: + - openrouter-api-key +metadata: + latency_class: variable + live_proof_model: qwen/qwen-2.5-72b-instruct diff --git a/profiles/harness.agent-dev.yaml b/profiles/harness.agent-dev.yaml index 053335a..8b4086a 100644 --- a/profiles/harness.agent-dev.yaml +++ b/profiles/harness.agent-dev.yaml @@ -1,10 +1,26 @@ id: harness.agent-dev version: "1.0.0" -rein: rein-aharness +contract_version: "1.0" +status: enabled +rein: + id: rein-aharness + required_capabilities: + session_style: unattended + model_class: frontier sandbox_profile: profile.agent-dev tool_profile: green-commit-only +model: + provider: anthropic + model: claude-sonnet-4-6 + model_class: frontier + route: claude-code-cli +limits: + budget_tokens: 60000 + timeout_seconds: 900 +credential_route_refs: [] metadata: latency_class: standard + stream_tool_events: true # Pairs with sand-boxer's profile.agent-dev (ext.compose-ssh, remote # host over SSH). Use harness.agent-dev-local for fast local iteration # without the SSH hop. diff --git a/pyproject.toml b/pyproject.toml index 854d4db..445c5b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,10 @@ dev = ["pytest>=8"] [tool.hatch.build.targets.wheel] packages = ["src/glas_harness"] +[tool.hatch.build.targets.wheel.force-include] +"profiles" = "glas_harness/data/profiles" +"registry/reins" = "glas_harness/data/reins" + [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] diff --git a/registry/README.md b/registry/README.md index 569abe9..c952fcc 100644 --- a/registry/README.md +++ b/registry/README.md @@ -2,6 +2,18 @@ Markdown-first capability index for federation and reuse planning. +`registry/reins/*.yaml` is also the executable rein registry consumed by +`ProfileCatalog`. Each descriptor has a semantic rein version, supported Glas +contract versions, a restricted in-package handler, capabilities, and status. +A profile is executable only when its rein is implemented, supports the exact +contract version, and satisfies every required capability. + +Validate the rein registry together with all harness profiles using: + +```bash +glas-harness profiles --json +``` + ## Authoring 1. Copy a capability entry template (see reuse-surface `templates/capability-entry.template.md`). diff --git a/registry/reins/rein-aharness.yaml b/registry/reins/rein-aharness.yaml index 46942c3..2d49a60 100644 --- a/registry/reins/rein-aharness.yaml +++ b/registry/reins/rein-aharness.yaml @@ -1,16 +1,15 @@ id: rein-aharness +version: "0.1.0" title: Claude Code CLI rein description: > Governed, unattended/scheduled harness driving Claude Code CLI sessions. Renamed from agent-harness (ADR-001). Deployed on Railiance for tenant binky-control. handler: glas_harness.reins.rein_aharness:ReinAharness +contract_versions: ["1.0"] capabilities: session_style: unattended model_class: frontier credential_source: rein-local - # rein-local: kaizen-agentic (blueprints), activity-core/issue-core - # (intake), OpenBao/ops-warden (credentials) — see - # rein-aharness/workplans/HARNESS-WP-0002-T04 for whether this stays - # rein-local or moves into glas-harness. + tool_event_visibility: complete status: implemented diff --git a/registry/reins/rein-openweights.yaml b/registry/reins/rein-openweights.yaml index 93e0db7..538f192 100644 --- a/registry/reins/rein-openweights.yaml +++ b/registry/reins/rein-openweights.yaml @@ -1,4 +1,5 @@ id: rein-openweights +version: "0.1.0" title: OpenRouter open-weight agentic loop description: > Agentic tool-use harness driving current open-weight models via @@ -6,11 +7,10 @@ description: > rein-openweights/workplans/REIN-OW-WP-0001-T01), as an alternative to frontier-vendor CLIs. Chartered by ADR-001. handler: glas_harness.reins.rein_openweights:ReinOpenWeights +contract_versions: ["1.0"] capabilities: session_style: unattended model_class: open-weight credential_source: rein-local - # rein-local per ADR-002 (Option B): rein-openweights acquires its own - # OpenRouter credential (OpenBao, or OPENROUTER_API_KEY env var). - # glas-harness does not broker it. + tool_event_visibility: unavailable status: implemented diff --git a/src/glas_harness/channels/base.py b/src/glas_harness/channels/base.py index 470264b..3675b99 100644 --- a/src/glas_harness/channels/base.py +++ b/src/glas_harness/channels/base.py @@ -17,7 +17,7 @@ from typing import Any class GatewayInvocation: """Everything run_task_through_rein needs, channel-agnostic.""" - sandbox_profile: str + harness_profile: str repo: str title: str description: str diff --git a/src/glas_harness/channels/cli_channel.py b/src/glas_harness/channels/cli_channel.py index 1577060..69259ab 100644 --- a/src/glas_harness/channels/cli_channel.py +++ b/src/glas_harness/channels/cli_channel.py @@ -18,7 +18,7 @@ from glas_harness.channels.base import Channel, GatewayInvocation class CLIChannel(Channel): def parse_invocation(self, raw: argparse.Namespace) -> GatewayInvocation: return GatewayInvocation( - sandbox_profile=raw.sandbox_profile, + harness_profile=raw.harness_profile, repo=raw.repo, title=raw.title, description=raw.description, diff --git a/src/glas_harness/cli.py b/src/glas_harness/cli.py index 1f60242..2f8547d 100644 --- a/src/glas_harness/cli.py +++ b/src/glas_harness/cli.py @@ -10,8 +10,14 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="glas-harness") sub = parser.add_subparsers(dest="command", required=True) - run = sub.add_parser("run", help="Run one task through a rein inside a sand-boxer sandbox") - run.add_argument("--sandbox-profile", required=True, help="e.g. profile.bwrap-local") + run = sub.add_parser( + "run", help="Run one task through a versioned Glas execution profile" + ) + run.add_argument( + "--harness-profile", + required=True, + help="Explicit profile id[@version], e.g. harness.agent-dev-local@1.0.0", + ) run.add_argument("--repo", required=True, help="Local repo path to mirror into the sandbox") run.add_argument("--title", required=True) run.add_argument("--description", required=True) @@ -19,25 +25,56 @@ def main(argv: list[str] | None = None) -> int: run.add_argument("--project", default="glas-harness") run.add_argument("--no-hub", action="store_true", help="Skip the gateway's own hub reporting") + profiles = sub.add_parser("profiles", help="Validate and list executable Glas profiles") + profiles.add_argument("--json", action="store_true", help="Emit machine-readable JSON") + args = parser.parse_args(argv) if args.command == "run": from glas_harness.channels.cli_channel import CLIChannel - from glas_harness.gateway import run_task_through_rein + from glas_harness.contract import ExecutionRequest + from glas_harness.gateway import run_execution channel = CLIChannel() invocation = channel.parse_invocation(args) - result = run_task_through_rein( - sandbox_profile=invocation.sandbox_profile, - repo=invocation.repo, - title=invocation.title, - description=invocation.description, - actor=invocation.actor, - project=invocation.project, - report_to_hub=invocation.report_to_hub, + result = run_execution( + ExecutionRequest( + harness_profile_ref=invocation.harness_profile, + repo=invocation.repo, + title=invocation.title, + description=invocation.description, + actor=invocation.actor, + project=invocation.project, + report_to_hub=invocation.report_to_hub, + ) ) - print(channel.render_result(result)) - return 0 if result["tool_ok"] else 1 + print(channel.render_result(result.model_dump(mode="json"))) + return 0 if result.ok else 1 + + if args.command == "profiles": + import json + + from glas_harness.profiles import ProfileCatalog, ProfileError + + try: + rows = [ + context.model_dump(mode="json") + for context in ProfileCatalog().validate_all() + ] + except ProfileError as exc: + print(f"invalid profile catalog: {exc}", file=sys.stderr) + return 2 + if args.json: + print(json.dumps(rows, indent=2)) + else: + for row in rows: + print( + f"{row['profile']['id']}@{row['profile']['version']}\t" + f"rein={row['rein_id']}@{row['rein_version']}\t" + f"model={row['model']['model']}\t" + f"sandbox={row['sandbox_profile']}" + ) + return 0 return 1 diff --git a/src/glas_harness/contract.py b/src/glas_harness/contract.py index 6a82192..b23a0ce 100644 --- a/src/glas_harness/contract.py +++ b/src/glas_harness/contract.py @@ -1,47 +1,202 @@ -"""The harness contract a concrete rein implements. +"""Versioned contract between glas-harness and concrete reins. -See docs/harness-contract.md for the full session-lifecycle diagram and -GLAS-WP-0001-T01. glas-harness owns the outer loop (profile resolution, -sandbox request/teardown via sand-boxer, State Hub reporting, actor -attribution); a rein owns the inner agentic loop. +Glas owns the outer lifecycle and the stable boundary models. A rein owns its +inner agentic loop. Contract models reject unknown fields so a profile or +backend cannot silently widen the execution surface. """ from __future__ import annotations +import re from abc import ABC, abstractmethod -from dataclasses import dataclass, field -from typing import Any +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +CONTRACT_VERSION = "1.0" +_SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$") -@dataclass -class SandboxHandle: - """What glas-harness got back from sand-boxer's create().""" +class ContractModel(BaseModel): + model_config = ConfigDict(extra="forbid") + +class HarnessProfileRef(ContractModel): + id: str = Field(pattern=r"^harness\.[a-z0-9][a-z0-9._-]*$") + version: str + + @field_validator("version") + @classmethod + def validate_version(cls, value: str) -> str: + if not _SEMVER.match(value): + raise ValueError("profile version must be semantic versioning") + return value + + def __str__(self) -> str: + return f"{self.id}@{self.version}" + + +class ModelRoute(ContractModel): + provider: str = Field(min_length=1) + model: str = Field(min_length=1) + model_class: Literal["frontier", "open-weight", "specialized", "other"] + route: str = Field(default="rein-native", min_length=1) + + +class ExecutionLimits(ContractModel): + budget_tokens: int | None = Field(default=None, gt=0) + timeout_seconds: int | None = Field(default=None, gt=0) + max_turns: int | None = Field(default=None, gt=0) + + +class ReinSelection(ContractModel): + id: str = Field(pattern=r"^rein-[a-z0-9][a-z0-9-]*$") + required_capabilities: dict[str, Any] = Field(default_factory=dict) + + +class HarnessProfile(ContractModel): + id: str = Field(pattern=r"^harness\.[a-z0-9][a-z0-9._-]*$") + version: str + contract_version: str + status: Literal["enabled", "disabled"] = "enabled" + rein: ReinSelection + sandbox_profile: str = Field(pattern=r"^profile\.[a-z0-9][a-z0-9._-]*$") + tool_profile: str = Field(min_length=1) + model: ModelRoute + limits: ExecutionLimits = Field(default_factory=ExecutionLimits) + credential_route_refs: list[str] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + + @field_validator("version") + @classmethod + def validate_version(cls, value: str) -> str: + return HarnessProfileRef.validate_version(value) + + @property + def ref(self) -> HarnessProfileRef: + return HarnessProfileRef(id=self.id, version=self.version) + + +class ReinDescriptor(ContractModel): + id: str = Field(pattern=r"^rein-[a-z0-9][a-z0-9-]*$") + version: str + title: str + description: str = "" + handler: str = Field(pattern=r"^glas_harness\.reins\.[A-Za-z0-9_]+:[A-Za-z0-9_]+$") + contract_versions: list[str] = Field(min_length=1) + capabilities: dict[str, Any] = Field(default_factory=dict) + status: Literal["implemented", "disabled", "experimental"] = "implemented" + + @field_validator("version") + @classmethod + def validate_version(cls, value: str) -> str: + return HarnessProfileRef.validate_version(value) + + +class SandboxHandle(ContractModel): sandbox_id: str host: str - reachability: dict[str, Any] = field(default_factory=dict) + reachability: dict[str, Any] = Field(default_factory=dict) -@dataclass -class ToolCall: +class ToolCall(ContractModel): name: str - args: dict[str, Any] = field(default_factory=dict) + args: dict[str, Any] = Field(default_factory=dict) actor: str = "agt" -@dataclass -class ToolResult: +class ToolResult(ContractModel): ok: bool output: str = "" error: str | None = None - # Per-tool-call audit trail, populated when the rein can observe its own - # inner loop's individual tool invocations in real time (e.g. parsing - # `claude --output-format stream-json`). Empty when a rein only exposes - # its whole task as one opaque call — that's a real limit of CLI-wrapped - # agents, not a shortcut: neither Claude Code nor rein-openweights's own - # loop lets glas-harness externally execute individual tool calls, only - # observe them. See HARNESS-WP-0002-T03. - events: list[dict[str, Any]] = field(default_factory=list) + events: list[dict[str, Any]] = Field(default_factory=list) + events_completeness: Literal["complete", "partial", "unavailable"] = "unavailable" + tokens_spent: int | None = Field(default=None, ge=0) + duration_s: float | None = Field(default=None, ge=0) + resolved_model: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ExecutionSummary(ContractModel): + committed: bool + commit_sha: str | None = None + outcome: Literal["succeeded", "failed", "refused"] + reason: str | None = None + tokens_spent: int | None = Field(default=None, ge=0) + duration_s: float | None = Field(default=None, ge=0) + resolved_model: str | None = None + artifacts: list[str] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ExecutionRequest(ContractModel): + harness_profile_ref: str = Field(min_length=1) + repo: str = Field(min_length=1) + title: str = Field(min_length=1) + description: str + actor: str = "agt" + project: str = "glas-harness" + request_id: str | None = None + correlation_id: str | None = None + assignment_ref: str | None = None + role_ref: str | None = None + duty_ref: str | None = None + goal_refs: list[str] = Field(default_factory=list) + resource_envelope_refs: list[str] = Field(default_factory=list) + expected_output: str | None = None + report_to_hub: bool = True + + +class ResolvedExecutionContext(ContractModel): + contract_version: str + profile: HarnessProfileRef + rein_id: str + rein_version: str + sandbox_profile: str + tool_profile: str + model: ModelRoute + limits: ExecutionLimits + + +class ExecutionEvidence(ContractModel): + request_id: str + correlation_id: str | None = None + actor: str + project: str + target_repo: str + contract_version: str = CONTRACT_VERSION + profile_ref: str | None = None + rein_id: str | None = None + rein_version: str | None = None + model_route: str | None = None + resolved_model: str | None = None + sandbox_profile: str | None = None + sandbox_id: str | None = None + tool_profile: str | None = None + outcome: Literal["succeeded", "failed", "refused"] + failure_stage: Literal[ + "resolution", "sandbox_create", "session_start", "execution", "session_end", "teardown" + ] | None = None + error: str | None = None + started_at: str + finished_at: str + duration_s: float = Field(ge=0) + tokens_spent: int | None = Field(default=None, ge=0) + token_budget: int | None = Field(default=None, ge=0) + commit_sha: str | None = None + artifacts: list[str] = Field(default_factory=list) + tool_events_count: int = Field(default=0, ge=0) + tool_events_completeness: Literal["complete", "partial", "unavailable"] = "unavailable" + refs: dict[str, Any] = Field(default_factory=dict) + + +class GatewayResult(ContractModel): + ok: bool + evidence: ExecutionEvidence + # Direct caller output only. gateway.py deliberately excludes these fields + # from State Hub detail because they can contain prompts/model responses. + tool_output: str = "" + tool_error: str | None = None class Rein(ABC): @@ -49,14 +204,14 @@ class Rein(ABC): @abstractmethod def start_session( - self, profile: dict[str, Any], inputs: dict[str, str], sandbox: SandboxHandle + self, profile: HarnessProfile, inputs: dict[str, str], sandbox: SandboxHandle ) -> dict[str, str]: - """Begin an agent session bound to a sandbox. Returns a session handle.""" + """Begin an agent session bound to a sandbox.""" @abstractmethod def dispatch_tool(self, session: dict[str, str], tool_call: ToolCall) -> ToolResult: - """Run one tool call under the session's policy.""" + """Run one tool call under the session policy.""" @abstractmethod - def end_session(self, session: dict[str, str]) -> dict[str, str]: - """Close the session. Returns a summary (commit sha, outcome, ...).""" + def end_session(self, session: dict[str, str]) -> ExecutionSummary: + """Close the session and return normalized rein evidence.""" diff --git a/src/glas_harness/gateway.py b/src/glas_harness/gateway.py index 8454819..cc4c32b 100644 --- a/src/glas_harness/gateway.py +++ b/src/glas_harness/gateway.py @@ -1,37 +1,201 @@ -"""Minimal gateway proving the harness contract against rein-aharness. +"""Profile-driven Glas gateway. -GLAS-WP-0001-T04: resolve a sand-boxer profile, request a sandbox, -dispatch one rein-aharness task through the Rein contract, verify a -commit landed, tear the sandbox down. This is the parity proof gating -any later "retire rein-aharness as a standalone concern" conversation — -it is not itself that conversation. - -GLAS-WP-0002-T03: post the gateway's own State Hub event, independent of -whatever the rein itself reports (both reins' CLIs are invoked with -their own hub reporting disabled by their glas-harness adapters — see -reins/rein_aharness.py / reins/rein_openweights.py). Reported on both -success and failure, from a `finally` block, so a raised exception still -leaves an audit trail. - -Requires the `sandbox` extra (sand-boxer installed as a sibling -editable dependency). +The gateway resolves a versioned harness profile before creating a sandbox. +There is deliberately no default rein or model in governed execution. """ from __future__ import annotations -from typing import Any +import time +import uuid +from datetime import UTC, datetime from sandboxer.core.manager import SandboxManager from sandboxer.models import Consumer, SandboxCreateRequest from glas_harness import hub -from glas_harness.contract import Rein, SandboxHandle, ToolCall -from glas_harness.reins.rein_aharness import ReinAharness +from glas_harness.contract import ( + CONTRACT_VERSION, + ExecutionEvidence, + ExecutionRequest, + ExecutionSummary, + GatewayResult, + Rein, + SandboxHandle, + ToolCall, + ToolResult, +) +from glas_harness.profiles import ProfileCatalog + + +def _now() -> str: + return datetime.now(UTC).isoformat() + + +def _request_refs(request: ExecutionRequest) -> dict: + refs = { + "assignment_ref": request.assignment_ref, + "role_ref": request.role_ref, + "duty_ref": request.duty_ref, + "goal_refs": request.goal_refs, + "resource_envelope_refs": request.resource_envelope_refs, + "expected_output": request.expected_output, + } + return {key: value for key, value in refs.items() if value not in (None, [], "")} + + +def run_execution( + request: ExecutionRequest, + *, + catalog: ProfileCatalog | None = None, + rein: Rein | None = None, + manager: SandboxManager | None = None, +) -> GatewayResult: + """Resolve and run one request, returning evidence for every outcome.""" + + request_id = request.request_id or str(uuid.uuid4()) + started_at = _now() + started = time.monotonic() + catalog = catalog or ProfileCatalog() + refs = _request_refs(request) + + profile = None + descriptor = None + sandbox_id: str | None = None + tool_result: ToolResult | None = None + summary: ExecutionSummary | None = None + outcome = "failed" + failure_stage = None + error: str | None = None + + try: + profile, descriptor = catalog.resolve(request.harness_profile_ref) + selected_rein = rein or catalog.build_rein(profile, descriptor) + except Exception as exc: + outcome = "refused" + failure_stage = "resolution" + error = str(exc) + result = _build_result( + request=request, + request_id=request_id, + started_at=started_at, + started=started, + outcome=outcome, + failure_stage=failure_stage, + error=error, + profile=profile, + descriptor=descriptor, + sandbox_id=None, + tool_result=None, + summary=None, + refs=refs, + ) + _report(request, result) + return result + + manager = manager or SandboxManager() + status = None + try: + try: + status = manager.create( + SandboxCreateRequest( + profile=profile.sandbox_profile, + inputs={"repo": request.repo}, + consumer=Consumer(actor=request.actor, project=request.project), + ttl=None, + ) + ) + sandbox_id = status.sandbox_id + except Exception as exc: + failure_stage = "sandbox_create" + error = str(exc) + raise + + reachability = ( + status.reachability.model_dump(mode="json", exclude_none=True) + if status.reachability + else {} + ) + sandbox = SandboxHandle( + sandbox_id=status.sandbox_id, + host=status.host or "", + reachability=reachability, + ) + + try: + session = selected_rein.start_session( + profile, + { + "title": request.title, + "description": request.description, + "target_repo": request.repo, + "request_id": request_id, + }, + sandbox, + ) + except Exception as exc: + failure_stage = "session_start" + error = str(exc) + raise + + try: + tool_result = selected_rein.dispatch_tool( + session, ToolCall(name="run_task", actor=request.actor) + ) + except Exception as exc: + failure_stage = "execution" + error = str(exc) + raise + + try: + summary = selected_rein.end_session(session) + except Exception as exc: + failure_stage = "session_end" + error = str(exc) + raise + + if tool_result.ok and summary.outcome == "succeeded": + outcome = "succeeded" + else: + outcome = summary.outcome if summary.outcome in {"failed", "refused"} else "failed" + failure_stage = "execution" + error = tool_result.error or summary.reason or "rein reported unsuccessful execution" + except Exception: + # The exact error/stage is captured above. All paths still tear down and + # return normalized evidence rather than leaking a provider exception. + pass + finally: + if status is not None: + try: + manager.destroy(status.sandbox_id) + except Exception as exc: + if outcome == "succeeded" or not error: + outcome = "failed" + failure_stage = "teardown" + error = str(exc) + + result = _build_result( + request=request, + request_id=request_id, + started_at=started_at, + started=started, + outcome=outcome, + failure_stage=failure_stage, + error=error, + profile=profile, + descriptor=descriptor, + sandbox_id=sandbox_id, + tool_result=tool_result, + summary=summary, + refs=refs, + ) + _report(request, result) + return result def run_task_through_rein( *, - sandbox_profile: str, + harness_profile: str, repo: str, title: str, description: str, @@ -39,89 +203,113 @@ def run_task_through_rein( actor: str = "agt", project: str = "glas-harness", manager: SandboxManager | None = None, + catalog: ProfileCatalog | None = None, report_to_hub: bool = True, -) -> dict[str, Any]: - """Resolve `sandbox_profile`, run one task inside it via `rein`, verify, tear down. +) -> dict: + """Compatibility-shaped wrapper around the versioned execution request. - Defaults to `ReinAharness` when no rein is supplied — the only - implemented rein as of GLAS-WP-0001-T04. `rein-openweights` plugs in - the same way once REIN-OW-WP-0001 lands. + It intentionally requires a harness profile. Direct rein injection is + retained only as a library/test seam and never selects a default backend. """ - manager = manager or SandboxManager() - rein = rein or ReinAharness() - request = SandboxCreateRequest( - profile=sandbox_profile, - inputs={"repo": repo}, - consumer=Consumer(actor=actor, project=project), + result = run_execution( + ExecutionRequest( + harness_profile_ref=harness_profile, + repo=repo, + title=title, + description=description, + actor=actor, + project=project, + report_to_hub=report_to_hub, + ), + catalog=catalog, + rein=rein, + manager=manager, ) - status = manager.create(request) - result: dict[str, Any] | None = None - error: str | None = None - try: - reachability = status.reachability.model_dump(mode="json") if status.reachability else {} - sandbox = SandboxHandle( - sandbox_id=status.sandbox_id, host=status.host or "", reachability=reachability - ) - - session = rein.start_session( - profile={"id": sandbox_profile}, - inputs={"title": title, "description": description}, - sandbox=sandbox, - ) - tool_result = rein.dispatch_tool(session, ToolCall(name="run_task", actor=actor)) - summary = rein.end_session(session) - - result = { - "sandbox_id": status.sandbox_id, - "tool_ok": tool_result.ok, - "tool_output": tool_result.output, - "tool_error": tool_result.error, - "summary": summary, - } - return result - except Exception as exc: - error = str(exc) - raise - finally: - manager.destroy(status.sandbox_id) - if report_to_hub: - _post_gateway_event( - rein=rein, - sandbox_profile=sandbox_profile, - sandbox_id=status.sandbox_id, - project=project, - actor=actor, - title=title, - result=result, - error=error, - ) + return result.model_dump(mode="json") -def _post_gateway_event( +def _build_result( *, - rein: Rein, - sandbox_profile: str, - sandbox_id: str, - project: str, - actor: str, - title: str, - result: dict[str, Any] | None, + request: ExecutionRequest, + request_id: str, + started_at: str, + started: float, + outcome: str, + failure_stage: str | None, error: str | None, -) -> None: - ok = bool(result and result.get("tool_ok")) - hub.post_progress_event( - summary=f"gateway run: {title} ({'ok' if ok else 'failed'})", - event_type="gateway_run", - detail={ - "sandbox_profile": sandbox_profile, - "sandbox_id": sandbox_id, - "rein": type(rein).__name__, - "project": project, - "actor": actor, - "task_title": title, - "ok": ok, - "result": result, - "error": error, - }, + profile, + descriptor, + sandbox_id: str | None, + tool_result: ToolResult | None, + summary: ExecutionSummary | None, + refs: dict, +) -> GatewayResult: + duration = max(0.0, time.monotonic() - started) + resolved_model = ( + (summary.resolved_model if summary else None) + or (tool_result.resolved_model if tool_result else None) + or (profile.model.model if profile else None) + ) + tokens_spent = ( + summary.tokens_spent if summary and summary.tokens_spent is not None + else tool_result.tokens_spent if tool_result else None + ) + execution_duration = ( + summary.duration_s if summary and summary.duration_s is not None + else tool_result.duration_s if tool_result else None + ) + evidence_error = error if failure_stage == "resolution" else ( + f"{failure_stage} failed; inspect direct caller error" if error and failure_stage else None + ) + evidence = ExecutionEvidence( + request_id=request_id, + correlation_id=request.correlation_id, + actor=request.actor, + project=request.project, + target_repo=request.repo, + contract_version=CONTRACT_VERSION, + profile_ref=str(profile.ref) if profile else None, + rein_id=descriptor.id if descriptor else None, + rein_version=descriptor.version if descriptor else None, + model_route=profile.model.route if profile else None, + resolved_model=resolved_model, + sandbox_profile=profile.sandbox_profile if profile else None, + sandbox_id=sandbox_id, + tool_profile=profile.tool_profile if profile else None, + outcome=outcome, + failure_stage=failure_stage, + error=evidence_error, + started_at=started_at, + finished_at=_now(), + duration_s=execution_duration if execution_duration is not None else duration, + tokens_spent=tokens_spent, + token_budget=profile.limits.budget_tokens if profile else None, + commit_sha=summary.commit_sha if summary else None, + artifacts=summary.artifacts if summary else [], + tool_events_count=len(tool_result.events) if tool_result else 0, + tool_events_completeness=( + tool_result.events_completeness if tool_result else "unavailable" + ), + refs=refs, + ) + return GatewayResult( + ok=outcome == "succeeded", + evidence=evidence, + tool_output=tool_result.output if tool_result else "", + tool_error=error or (tool_result.error if tool_result else None), + ) + + +def _report(request: ExecutionRequest, result: GatewayResult) -> None: + if not request.report_to_hub: + return + evidence = result.evidence.model_dump(mode="json", exclude_none=True) + hub.post_progress_event( + summary=( + f"gateway run: {request.title} " + f"({'ok' if result.ok else result.evidence.outcome})" + ), + event_type="gateway_run", + detail=evidence, ) diff --git a/src/glas_harness/profiles.py b/src/glas_harness/profiles.py new file mode 100644 index 0000000..eac3ff2 --- /dev/null +++ b/src/glas_harness/profiles.py @@ -0,0 +1,222 @@ +"""Runtime loader for versioned Glas harness profiles and rein descriptors.""" + +from __future__ import annotations + +import importlib +import inspect +import os +import re +from pathlib import Path +from typing import Any + +import yaml +from pydantic import ValidationError + +from glas_harness.contract import ( + CONTRACT_VERSION, + HarnessProfile, + Rein, + ReinDescriptor, + ResolvedExecutionContext, +) + + +class ProfileError(ValueError): + pass + + +class UnknownProfileError(ProfileError): + pass + + +class AmbiguousProfileError(ProfileError): + pass + + +class IncompatibleProfileError(ProfileError): + pass + + +_SENSITIVE_KEY = re.compile( + r"(^|_)(api_key|password|passwd|secret|secret_value|token_value|private_key)$", + re.IGNORECASE, +) +_SENSITIVE_VALUE = re.compile(r"^(sk-[A-Za-z0-9_-]{12,}|hvs\.[A-Za-z0-9_-]{12,})$") + + +def _source_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _default_data_dir(kind: str) -> Path: + env_name = "GLAS_PROFILE_DIR" if kind == "profiles" else "GLAS_REIN_REGISTRY_DIR" + if configured := os.environ.get(env_name): + return Path(configured).expanduser().resolve() + packaged = Path(__file__).resolve().parent / "data" / kind + if packaged.is_dir(): + return packaged + return _source_root() / ("profiles" if kind == "profiles" else "registry/reins") + + +def _read_yaml(path: Path) -> dict[str, Any]: + try: + data = yaml.safe_load(path.read_text()) + except (OSError, yaml.YAMLError) as exc: + raise ProfileError(f"cannot read {path}: {exc}") from exc + if not isinstance(data, dict): + raise ProfileError(f"{path}: expected a YAML object") + _reject_inline_secrets(data, path=path) + return data + + +def _reject_inline_secrets(value: Any, *, path: Path, key_path: str = "") -> None: + if isinstance(value, dict): + for key, child in value.items(): + key_str = str(key) + child_path = f"{key_path}.{key_str}" if key_path else key_str + if _SENSITIVE_KEY.search(key_str) and child not in (None, "", [], {}): + raise ProfileError(f"{path}: inline secret material forbidden at {child_path}") + _reject_inline_secrets(child, path=path, key_path=child_path) + elif isinstance(value, list): + for index, child in enumerate(value): + _reject_inline_secrets(child, path=path, key_path=f"{key_path}[{index}]") + elif isinstance(value, str) and _SENSITIVE_VALUE.match(value): + raise ProfileError(f"{path}: token-looking inline value forbidden at {key_path}") + + +def _capability_satisfies(actual: Any, required: Any) -> bool: + if isinstance(required, list): + if not isinstance(actual, list): + return False + return all(item in actual for item in required) + return actual == required + + +class ProfileCatalog: + def __init__( + self, + profile_dir: str | Path | None = None, + rein_dir: str | Path | None = None, + ) -> None: + self.profile_dir = Path(profile_dir) if profile_dir else _default_data_dir("profiles") + self.rein_dir = Path(rein_dir) if rein_dir else _default_data_dir("reins") + self._profiles: dict[tuple[str, str], HarnessProfile] | None = None + self._reins: dict[str, ReinDescriptor] | None = None + + def profiles(self) -> dict[tuple[str, str], HarnessProfile]: + if self._profiles is None: + loaded: dict[tuple[str, str], HarnessProfile] = {} + for path in sorted(self.profile_dir.glob("*.yaml")): + try: + profile = HarnessProfile.model_validate(_read_yaml(path)) + except ValidationError as exc: + raise ProfileError(f"{path}: invalid harness profile: {exc}") from exc + key = (profile.id, profile.version) + if key in loaded: + raise ProfileError(f"duplicate harness profile {profile.id}@{profile.version}") + loaded[key] = profile + self._profiles = loaded + return self._profiles + + def reins(self) -> dict[str, ReinDescriptor]: + if self._reins is None: + loaded: dict[str, ReinDescriptor] = {} + for path in sorted(self.rein_dir.glob("*.yaml")): + try: + descriptor = ReinDescriptor.model_validate(_read_yaml(path)) + except ValidationError as exc: + raise ProfileError(f"{path}: invalid rein descriptor: {exc}") from exc + if descriptor.id in loaded: + raise ProfileError(f"duplicate rein descriptor {descriptor.id}") + loaded[descriptor.id] = descriptor + self._reins = loaded + return self._reins + + def resolve(self, reference: str) -> tuple[HarnessProfile, ReinDescriptor]: + profile_id, separator, version = reference.partition("@") + candidates = [ + profile + for (candidate_id, candidate_version), profile in self.profiles().items() + if candidate_id == profile_id and (not separator or candidate_version == version) + ] + if not candidates: + raise UnknownProfileError(f"unknown harness profile: {reference}") + if len(candidates) != 1: + refs = ", ".join(sorted(str(candidate.ref) for candidate in candidates)) + raise AmbiguousProfileError( + f"ambiguous harness profile {reference}; pin one of: {refs}" + ) + profile = candidates[0] + if profile.status != "enabled": + raise IncompatibleProfileError(f"harness profile disabled: {profile.ref}") + if profile.contract_version != CONTRACT_VERSION: + raise IncompatibleProfileError( + f"profile {profile.ref} requires contract {profile.contract_version}; " + f"gateway supports {CONTRACT_VERSION}" + ) + + descriptor = self.reins().get(profile.rein.id) + if descriptor is None: + raise IncompatibleProfileError( + f"profile {profile.ref} references unknown rein {profile.rein.id}" + ) + if descriptor.status != "implemented": + raise IncompatibleProfileError( + f"rein {descriptor.id} is not enabled for governed execution " + f"(status={descriptor.status})" + ) + if profile.contract_version not in descriptor.contract_versions: + raise IncompatibleProfileError( + f"rein {descriptor.id}@{descriptor.version} does not implement " + f"contract {profile.contract_version}" + ) + for name, required in profile.rein.required_capabilities.items(): + actual = descriptor.capabilities.get(name) + if not _capability_satisfies(actual, required): + raise IncompatibleProfileError( + f"rein {descriptor.id} capability {name!r} is {actual!r}; " + f"profile requires {required!r}" + ) + return profile, descriptor + + def resolve_context(self, reference: str) -> ResolvedExecutionContext: + profile, descriptor = self.resolve(reference) + return ResolvedExecutionContext( + contract_version=CONTRACT_VERSION, + profile=profile.ref, + rein_id=descriptor.id, + rein_version=descriptor.version, + sandbox_profile=profile.sandbox_profile, + tool_profile=profile.tool_profile, + model=profile.model, + limits=profile.limits, + ) + + def build_rein(self, profile: HarnessProfile, descriptor: ReinDescriptor) -> Rein: + module_name, class_name = descriptor.handler.split(":", 1) + module = importlib.import_module(module_name) + rein_type = getattr(module, class_name) + if not inspect.isclass(rein_type) or not issubclass(rein_type, Rein): + raise IncompatibleProfileError( + f"handler {descriptor.handler} does not implement Rein" + ) + candidate_kwargs = { + "model": profile.model.model, + "tool_profile": profile.tool_profile, + "max_turns": profile.limits.max_turns, + "budget_tokens": profile.limits.budget_tokens, + "stream_tool_events": bool(profile.metadata.get("stream_tool_events", False)), + } + parameters = inspect.signature(rein_type).parameters + kwargs = { + name: value + for name, value in candidate_kwargs.items() + if name in parameters and value is not None + } + return rein_type(**kwargs) + + def validate_all(self) -> list[ResolvedExecutionContext]: + return [ + self.resolve_context(str(profile.ref)) + for profile in sorted(self.profiles().values(), key=lambda item: (item.id, item.version)) + ] diff --git a/src/glas_harness/reins/_shared.py b/src/glas_harness/reins/_shared.py index a164496..9215b0e 100644 --- a/src/glas_harness/reins/_shared.py +++ b/src/glas_harness/reins/_shared.py @@ -26,3 +26,19 @@ def write_task_file(title: str, description: str, target_repo: str, **extra: Any json.dump(task_spec, fd) fd.close() return fd.name + + +def parse_json_object(text: str) -> dict[str, Any]: + """Parse a rein CLI's final JSON object from otherwise human-readable output.""" + + decoder = json.JSONDecoder() + for index, character in enumerate(text): + if character != "{": + continue + try: + value, end = decoder.raw_decode(text[index:]) + except json.JSONDecodeError: + continue + if isinstance(value, dict) and not text[index + end :].strip(): + return value + return {} diff --git a/src/glas_harness/reins/rein_aharness.py b/src/glas_harness/reins/rein_aharness.py index 9ca4f55..7e3a8ee 100644 --- a/src/glas_harness/reins/rein_aharness.py +++ b/src/glas_harness/reins/rein_aharness.py @@ -25,8 +25,15 @@ import shutil import subprocess from typing import Any -from glas_harness.contract import Rein, SandboxHandle, ToolCall, ToolResult -from glas_harness.reins._shared import git_head, write_task_file +from glas_harness.contract import ( + ExecutionSummary, + HarnessProfile, + Rein, + SandboxHandle, + ToolCall, + ToolResult, +) +from glas_harness.reins._shared import git_head, parse_json_object, write_task_file class ReinAharnessNotInstalled(RuntimeError): @@ -34,9 +41,20 @@ class ReinAharnessNotInstalled(RuntimeError): class ReinAharness(Rein): - def __init__(self, cli_bin: str = "rein-aharness", stream_tool_events: bool = False) -> None: + def __init__( + self, + cli_bin: str = "rein-aharness", + stream_tool_events: bool = False, + model: str | None = None, + tool_profile: str | None = None, + budget_tokens: int | None = None, + ) -> None: self.cli_bin = cli_bin self.stream_tool_events = stream_tool_events + self.model = model + self.tool_profile = tool_profile + self.budget_tokens = budget_tokens + self._last_result: dict[str, Any] = {} def _bin(self) -> str: resolved = shutil.which(self.cli_bin) @@ -47,7 +65,7 @@ class ReinAharness(Rein): return resolved def start_session( - self, profile: dict[str, Any], inputs: dict[str, str], sandbox: SandboxHandle + self, profile: HarnessProfile, inputs: dict[str, str], sandbox: SandboxHandle ) -> dict[str, str]: target_repo = ( inputs.get("target_repo") @@ -58,7 +76,11 @@ class ReinAharness(Rein): raise ValueError("no target_repo resolvable from inputs or sandbox reachability") task_file = inputs.get("task_file") or write_task_file( - inputs["title"], inputs["description"], target_repo, agent=inputs.get("agent", "coach") + inputs["title"], + inputs["description"], + target_repo, + agent=inputs.get("agent", "coach"), + timeout_seconds=profile.limits.timeout_seconds or 600, ) return { @@ -79,10 +101,29 @@ class ReinAharness(Rein): ] if self.stream_tool_events: argv.append("--stream-tool-events") + if self.model: + argv += ["--model", self.model] + if self.tool_profile: + argv += ["--tool-profile", self.tool_profile] + if self.budget_tokens: + argv += ["--budget-tokens", str(self.budget_tokens)] proc = subprocess.run(argv, capture_output=True, text=True) ok = proc.returncode == 0 output, events = self._split_stream_events(proc.stdout) - return ToolResult(ok=ok, output=output, error=None if ok else proc.stderr, events=events) + self._last_result = parse_json_object(output) + return ToolResult( + ok=ok, + output=output, + error=None if ok else (proc.stderr or self._last_result.get("reason")), + events=events, + events_completeness="complete" if self.stream_tool_events else "unavailable", + tokens_spent=self._last_result.get("tokens_spent"), + duration_s=self._last_result.get("execution_time_s"), + resolved_model=self._last_result.get("model") or self.model, + metadata={ + "tool_profile": self._last_result.get("tool_profile") or self.tool_profile, + }, + ) @staticmethod def _split_stream_events(stdout: str) -> tuple[str, list[dict[str, Any]]]: @@ -105,10 +146,25 @@ class ReinAharness(Rein): remaining_lines.append(line) return "\n".join(remaining_lines), events - def end_session(self, session: dict[str, str]) -> dict[str, str]: + def end_session(self, session: dict[str, str]) -> ExecutionSummary: head_after = git_head(session["target_repo"]) committed = bool(head_after) and head_after != session.get("head_before") - return { - "commit_sha": head_after or "", - "committed": str(committed), - } + reported_ok = bool(self._last_result.get("ok", committed)) + reason = self._last_result.get("reason") or None + outcome = "succeeded" if committed and reported_ok else ( + "refused" if isinstance(reason, str) and reason.startswith("refused:") else "failed" + ) + return ExecutionSummary( + commit_sha=head_after or None, + committed=committed, + outcome=outcome, + reason=reason, + tokens_spent=self._last_result.get("tokens_spent"), + duration_s=self._last_result.get("execution_time_s"), + resolved_model=self._last_result.get("model") or self.model, + artifacts=[head_after] if committed and head_after else [], + metadata={ + "tool_profile": self._last_result.get("tool_profile") or self.tool_profile, + "persona_source": self._last_result.get("persona_source"), + }, + ) diff --git a/src/glas_harness/reins/rein_openweights.py b/src/glas_harness/reins/rein_openweights.py index bdbcc8a..c540546 100644 --- a/src/glas_harness/reins/rein_openweights.py +++ b/src/glas_harness/reins/rein_openweights.py @@ -13,8 +13,15 @@ import shutil import subprocess from typing import Any -from glas_harness.contract import Rein, SandboxHandle, ToolCall, ToolResult -from glas_harness.reins._shared import git_head, write_task_file +from glas_harness.contract import ( + ExecutionSummary, + HarnessProfile, + Rein, + SandboxHandle, + ToolCall, + ToolResult, +) +from glas_harness.reins._shared import git_head, parse_json_object, write_task_file class ReinOpenWeightsNotInstalled(RuntimeError): @@ -22,9 +29,20 @@ class ReinOpenWeightsNotInstalled(RuntimeError): class ReinOpenWeights(Rein): - def __init__(self, cli_bin: str = "rein-openweights", model: str | None = None) -> None: + def __init__( + self, + cli_bin: str = "rein-openweights", + model: str | None = None, + max_turns: int | None = None, + budget_tokens: int | None = None, + tool_profile: str | None = None, + ) -> None: self.cli_bin = cli_bin self.model = model + self.max_turns = max_turns + self.budget_tokens = budget_tokens + self.tool_profile = tool_profile + self._last_result: dict[str, Any] = {} def _bin(self) -> str: resolved = shutil.which(self.cli_bin) @@ -35,7 +53,7 @@ class ReinOpenWeights(Rein): return resolved def start_session( - self, profile: dict[str, Any], inputs: dict[str, str], sandbox: SandboxHandle + self, profile: HarnessProfile, inputs: dict[str, str], sandbox: SandboxHandle ) -> dict[str, str]: target_repo = ( inputs.get("target_repo") @@ -46,7 +64,10 @@ class ReinOpenWeights(Rein): raise ValueError("no target_repo resolvable from inputs or sandbox reachability") task_file = inputs.get("task_file") or write_task_file( - inputs["title"], inputs["description"], target_repo + inputs["title"], + inputs["description"], + target_repo, + timeout_seconds=profile.limits.timeout_seconds or 600, ) return { @@ -60,14 +81,48 @@ class ReinOpenWeights(Rein): argv = [self._bin(), "run", "--task-file", session["task_file"], "--no-hub"] if self.model: argv += ["--model", self.model] + if self.max_turns: + argv += ["--max-turns", str(self.max_turns)] + if self.budget_tokens: + argv += ["--budget-tokens", str(self.budget_tokens)] + if self.tool_profile: + argv += ["--tool-profile", self.tool_profile] proc = subprocess.run(argv, capture_output=True, text=True) ok = proc.returncode == 0 - return ToolResult(ok=ok, output=proc.stdout, error=None if ok else proc.stderr) + self._last_result = parse_json_object(proc.stdout) + return ToolResult( + ok=ok, + output=proc.stdout, + error=None if ok else (proc.stderr or self._last_result.get("reason")), + events_completeness="unavailable", + tokens_spent=self._last_result.get("tokens_spent"), + duration_s=self._last_result.get("execution_time_s"), + resolved_model=self._last_result.get("model") or self.model, + metadata={ + "turns": self._last_result.get("turns"), + "tool_profile": self._last_result.get("tool_profile") or self.tool_profile, + }, + ) - def end_session(self, session: dict[str, str]) -> dict[str, str]: + def end_session(self, session: dict[str, str]) -> ExecutionSummary: head_after = git_head(session["target_repo"]) committed = bool(head_after) and head_after != session.get("head_before") - return { - "commit_sha": head_after or "", - "committed": str(committed), - } + reported_ok = bool(self._last_result.get("ok", committed)) + reason = self._last_result.get("reason") or None + outcome = "succeeded" if committed and reported_ok else ( + "refused" if reason == "no OpenRouter credential resolved" else "failed" + ) + return ExecutionSummary( + commit_sha=head_after or None, + committed=committed, + outcome=outcome, + reason=reason, + tokens_spent=self._last_result.get("tokens_spent"), + duration_s=self._last_result.get("execution_time_s"), + resolved_model=self._last_result.get("model") or self.model, + artifacts=[head_after] if committed and head_after else [], + metadata={ + "turns": self._last_result.get("turns"), + "tool_profile": self._last_result.get("tool_profile") or self.tool_profile, + }, + ) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..bf7b215 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,21 @@ +import json +from pathlib import Path + +from glas_harness.cli import main +from glas_harness.contract import ExecutionRequest + + +def test_profiles_command_lists_committed_constellations(capsys) -> None: + assert main(["profiles", "--json"]) == 0 + + rows = json.loads(capsys.readouterr().out) + assert {row["rein_id"] for row in rows} == {"rein-aharness", "rein-openweights"} + + +def test_execution_request_example_matches_contract() -> None: + fixture = Path(__file__).resolve().parents[1] / "examples" / "execution-request.json" + + request = ExecutionRequest.model_validate_json(fixture.read_text()) + + assert request.harness_profile_ref == "harness.agent-dev-local@1.0.0" + assert request.assignment_ref == "role-assignment:agent-7:42" diff --git a/tests/test_cli_channel.py b/tests/test_cli_channel.py index c205800..2b1edd2 100644 --- a/tests/test_cli_channel.py +++ b/tests/test_cli_channel.py @@ -11,7 +11,7 @@ def test_cli_channel_is_a_channel() -> None: def _args(**overrides) -> argparse.Namespace: defaults = dict( - sandbox_profile="profile.bwrap-local", + harness_profile="harness.agent-dev-local@1.0.0", repo="/tmp/repo", title="t", description="d", @@ -28,7 +28,7 @@ def test_parse_invocation_maps_all_fields() -> None: invocation = channel.parse_invocation(_args()) assert invocation == GatewayInvocation( - sandbox_profile="profile.bwrap-local", + harness_profile="harness.agent-dev-local@1.0.0", repo="/tmp/repo", title="t", description="d", diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 9cb01cb..7ce7bbd 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -3,8 +3,18 @@ from unittest.mock import MagicMock, patch from sandboxer.models import Reachability, SandboxState, SandboxStatus -from glas_harness.contract import Rein, SandboxHandle, ToolCall, ToolResult -from glas_harness.gateway import run_task_through_rein +from glas_harness.contract import ( + ExecutionRequest, + ExecutionSummary, + Rein, + SandboxHandle, + ToolCall, + ToolResult, +) +from glas_harness.gateway import run_execution, run_task_through_rein + + +PROFILE = "harness.agent-dev-local@1.0.0" class _FakeRein(Rein): @@ -13,6 +23,7 @@ class _FakeRein(Rein): def start_session(self, profile, inputs, sandbox: SandboxHandle): self.calls.append("start_session") + assert str(profile.ref) == PROFILE assert sandbox.sandbox_id == "sbx1" assert sandbox.reachability.get("workspace_dir") == "/tmp/ws" return {"session": "s1"} @@ -20,11 +31,24 @@ class _FakeRein(Rein): def dispatch_tool(self, session, tool_call: ToolCall) -> ToolResult: self.calls.append("dispatch_tool") assert tool_call.name == "run_task" - return ToolResult(ok=True, output="done") + return ToolResult( + ok=True, + output="sensitive direct output", + events=[{"type": "tool_use"}], + events_completeness="complete", + tokens_spent=123, + resolved_model="claude-sonnet-4-6", + ) def end_session(self, session): self.calls.append("end_session") - return {"commit_sha": "deadbeef", "committed": "True"} + return ExecutionSummary( + committed=True, + commit_sha="deadbeef", + outcome="succeeded", + tokens_spent=123, + resolved_model="claude-sonnet-4-6", + ) def _fake_status(sandbox_id: str = "sbx1") -> SandboxStatus: @@ -42,29 +66,39 @@ def _fake_status(sandbox_id: str = "sbx1") -> SandboxStatus: ) -def test_run_task_through_rein_creates_and_destroys_sandbox() -> None: +def _request(*, profile: str = PROFILE, report_to_hub: bool = False) -> ExecutionRequest: + return ExecutionRequest( + harness_profile_ref=profile, + repo="/tmp/repo", + title="t", + description="d", + request_id="req-1", + correlation_id="corr-1", + assignment_ref="assignment:42", + report_to_hub=report_to_hub, + ) + + +def test_run_execution_creates_and_destroys_sandbox() -> None: manager = MagicMock() manager.create.return_value = _fake_status() rein = _FakeRein() - result = run_task_through_rein( - sandbox_profile="profile.bwrap-local", - repo="/tmp/repo", - title="t", - description="d", - rein=rein, - manager=manager, - report_to_hub=False, - ) + result = run_execution(_request(), rein=rein, manager=manager) assert rein.calls == ["start_session", "dispatch_tool", "end_session"] manager.create.assert_called_once() manager.destroy.assert_called_once_with("sbx1") - assert result["tool_ok"] is True - assert result["summary"]["committed"] == "True" + assert result.ok is True + assert result.evidence.profile_ref == PROFILE + assert result.evidence.rein_id == "rein-aharness" + assert result.evidence.commit_sha == "deadbeef" + assert result.evidence.tokens_spent == 123 + assert result.evidence.refs["assignment_ref"] == "assignment:42" + assert result.tool_output == "sensitive direct output" -def test_run_task_through_rein_destroys_sandbox_even_on_failure() -> None: +def test_run_execution_normalizes_execution_failure_and_tears_down() -> None: manager = MagicMock() manager.create.return_value = _fake_status() @@ -72,94 +106,89 @@ def test_run_task_through_rein_destroys_sandbox_even_on_failure() -> None: def dispatch_tool(self, session, tool_call): raise RuntimeError("boom") - rein = _FailingRein() - - try: - run_task_through_rein( - sandbox_profile="profile.bwrap-local", - repo="/tmp/repo", - title="t", - description="d", - rein=rein, - manager=manager, - report_to_hub=False, - ) - except RuntimeError: - pass + result = run_execution(_request(), rein=_FailingRein(), manager=manager) + assert result.ok is False + assert result.evidence.outcome == "failed" + assert result.evidence.failure_stage == "execution" + assert result.evidence.error == "execution failed; inspect direct caller error" + assert result.tool_error == "boom" manager.destroy.assert_called_once_with("sbx1") -def test_run_task_through_rein_reports_success_event() -> None: +def test_run_execution_refuses_unknown_profile_before_sandbox() -> None: + manager = MagicMock() + + result = run_execution(_request(profile="harness.unknown@1.0.0"), manager=manager) + + assert result.ok is False + assert result.evidence.outcome == "refused" + assert result.evidence.failure_stage == "resolution" + assert "unknown harness profile" in (result.evidence.error or "") + manager.create.assert_not_called() + + +def test_hub_receives_normalized_evidence_without_raw_output() -> None: manager = MagicMock() manager.create.return_value = _fake_status() - rein = _FakeRein() with patch("glas_harness.gateway.hub.post_progress_event", return_value=True) as post: - run_task_through_rein( - sandbox_profile="profile.bwrap-local", - repo="/tmp/repo", - title="t", - description="d", - rein=rein, - manager=manager, - ) + result = run_execution(_request(report_to_hub=True), rein=_FakeRein(), manager=manager) post.assert_called_once() - kwargs = post.call_args.kwargs - assert kwargs["event_type"] == "gateway_run" - assert "ok)" in kwargs["summary"] - assert kwargs["detail"]["ok"] is True - assert kwargs["detail"]["sandbox_id"] == "sbx1" - assert kwargs["detail"]["rein"] == "_FakeRein" + detail = post.call_args.kwargs["detail"] + assert detail["outcome"] == "succeeded" + assert detail["profile_ref"] == PROFILE + assert "tool_output" not in detail + assert "sensitive direct output" not in str(detail) + assert result.tool_output == "sensitive direct output" -def test_run_task_through_rein_reports_failure_event_and_still_raises() -> None: +def test_hub_failure_detail_excludes_raw_provider_error() -> None: manager = MagicMock() manager.create.return_value = _fake_status() class _FailingRein(_FakeRein): def dispatch_tool(self, session, tool_call): - raise RuntimeError("boom") - - rein = _FailingRein() + raise RuntimeError("provider body containing sensitive material") with patch("glas_harness.gateway.hub.post_progress_event", return_value=True) as post: - try: - run_task_through_rein( - sandbox_profile="profile.bwrap-local", - repo="/tmp/repo", - title="t", - description="d", - rein=rein, - manager=manager, - ) - assert False, "expected RuntimeError to propagate" - except RuntimeError: - pass - - post.assert_called_once() - kwargs = post.call_args.kwargs - assert "failed)" in kwargs["summary"] - assert kwargs["detail"]["ok"] is False - assert kwargs["detail"]["error"] == "boom" - assert kwargs["detail"]["result"] is None - - -def test_run_task_through_rein_skips_hub_when_disabled() -> None: - manager = MagicMock() - manager.create.return_value = _fake_status() - rein = _FakeRein() - - with patch("glas_harness.gateway.hub.post_progress_event") as post: - run_task_through_rein( - sandbox_profile="profile.bwrap-local", - repo="/tmp/repo", - title="t", - description="d", - rein=rein, - manager=manager, - report_to_hub=False, + result = run_execution( + _request(report_to_hub=True), rein=_FailingRein(), manager=manager ) - post.assert_not_called() + detail = post.call_args.kwargs["detail"] + assert detail["error"] == "execution failed; inspect direct caller error" + assert "sensitive material" not in str(detail) + assert "sensitive material" in (result.tool_error or "") + + +def test_wrapper_requires_and_reports_harness_profile() -> None: + manager = MagicMock() + manager.create.return_value = _fake_status() + + result = run_task_through_rein( + harness_profile=PROFILE, + repo="/tmp/repo", + title="t", + description="d", + rein=_FakeRein(), + manager=manager, + report_to_hub=False, + ) + + assert result["ok"] is True + assert result["evidence"]["profile_ref"] == PROFILE + + +def test_teardown_failure_is_visible_in_evidence() -> None: + manager = MagicMock() + manager.create.return_value = _fake_status() + manager.destroy.side_effect = RuntimeError("cannot teardown") + + result = run_execution(_request(), rein=_FakeRein(), manager=manager) + + assert result.ok is False + assert result.evidence.failure_stage == "teardown" + assert result.evidence.error == "teardown failed; inspect direct caller error" + assert result.tool_error == "cannot teardown" diff --git a/tests/test_profiles.py b/tests/test_profiles.py new file mode 100644 index 0000000..161569e --- /dev/null +++ b/tests/test_profiles.py @@ -0,0 +1,164 @@ +from pathlib import Path + +import pytest + +from glas_harness.profiles import ( + AmbiguousProfileError, + IncompatibleProfileError, + ProfileCatalog, + ProfileError, + UnknownProfileError, +) +from glas_harness.reins.rein_aharness import ReinAharness +from glas_harness.reins.rein_openweights import ReinOpenWeights + + +def _write(path: Path, name: str, text: str) -> None: + (path / name).write_text(text) + + +def _profile( + *, + profile_id: str = "harness.test", + version: str = "1.0.0", + contract: str = "1.0", + rein: str = "rein-test", + required: str = "session_style: unattended", + extra: str = "", +) -> str: + return f"""\ +id: {profile_id} +version: \"{version}\" +contract_version: \"{contract}\" +status: enabled +rein: + id: {rein} + required_capabilities: + {required} +sandbox_profile: profile.test +tool_profile: green-commit-only +model: + provider: test + model: model-1 + model_class: other + route: test-route +limits: + budget_tokens: 100 +{extra} +""" + + +def _rein(*, rein_id: str = "rein-test", capability: str = "unattended") -> str: + return f"""\ +id: {rein_id} +version: \"1.0.0\" +title: Test rein +handler: glas_harness.reins.rein_aharness:ReinAharness +contract_versions: [\"1.0\"] +capabilities: + session_style: {capability} +status: implemented +""" + + +def test_committed_catalog_resolves_both_constellations() -> None: + catalog = ProfileCatalog() + + contexts = catalog.validate_all() + + assert len(contexts) == 3 + assert {context.rein_id for context in contexts} == { + "rein-aharness", + "rein-openweights", + } + profile, descriptor = catalog.resolve( + "harness.agent-dev-openweights-local@1.0.0" + ) + rein = catalog.build_rein(profile, descriptor) + assert isinstance(rein, ReinOpenWeights) + assert rein.model == "qwen/qwen-2.5-72b-instruct" + assert rein.tool_profile == "green-commit-only" + assert rein.max_turns == 20 + assert rein.budget_tokens == 60000 + + profile, descriptor = catalog.resolve("harness.agent-dev-local@1.0.0") + rein = catalog.build_rein(profile, descriptor) + assert isinstance(rein, ReinAharness) + assert rein.model == "claude-sonnet-4-6" + assert rein.tool_profile == "green-commit-only" + assert rein.budget_tokens == 60000 + + +def test_unknown_profile_fails_closed() -> None: + with pytest.raises(UnknownProfileError, match="unknown harness profile"): + ProfileCatalog().resolve("harness.missing@1.0.0") + + +def test_unpinned_multi_version_profile_is_ambiguous(tmp_path) -> None: + profiles = tmp_path / "profiles" + reins = tmp_path / "reins" + profiles.mkdir() + reins.mkdir() + _write(profiles, "one.yaml", _profile(version="1.0.0")) + _write(profiles, "two.yaml", _profile(version="2.0.0")) + _write(reins, "rein.yaml", _rein()) + + with pytest.raises(AmbiguousProfileError, match="pin one of"): + ProfileCatalog(profiles, reins).resolve("harness.test") + + +@pytest.mark.parametrize( + ("profile_text", "match"), + [ + (_profile(version="not-semver"), "semantic versioning"), + (_profile(extra="unexpected: true"), "extra_forbidden"), + (_profile(extra="api_key: sk-this-is-inline-and-forbidden"), "inline secret"), + ], +) +def test_malformed_or_sensitive_profiles_are_rejected( + tmp_path, profile_text: str, match: str +) -> None: + profiles = tmp_path / "profiles" + reins = tmp_path / "reins" + profiles.mkdir() + reins.mkdir() + _write(profiles, "profile.yaml", profile_text) + _write(reins, "rein.yaml", _rein()) + + with pytest.raises(ProfileError, match=match): + ProfileCatalog(profiles, reins).profiles() + + +def test_duplicate_profile_revision_is_rejected(tmp_path) -> None: + profiles = tmp_path / "profiles" + reins = tmp_path / "reins" + profiles.mkdir() + reins.mkdir() + _write(profiles, "one.yaml", _profile()) + _write(profiles, "two.yaml", _profile()) + _write(reins, "rein.yaml", _rein()) + + with pytest.raises(ProfileError, match="duplicate harness profile"): + ProfileCatalog(profiles, reins).profiles() + + +@pytest.mark.parametrize( + ("profile_text", "rein_text", "match"), + [ + (_profile(contract="2.0"), _rein(), "requires contract 2.0"), + (_profile(rein="rein-missing"), _rein(), "unknown rein"), + (_profile(required="session_style: interactive"), _rein(), "capability"), + ], +) +def test_incompatible_profile_or_rein_is_rejected( + tmp_path, profile_text: str, rein_text: str, match: str +) -> None: + profiles = tmp_path / "profiles" + reins = tmp_path / "reins" + profiles.mkdir() + reins.mkdir() + _write(profiles, "profile.yaml", profile_text) + _write(reins, "rein.yaml", rein_text) + + with pytest.raises(IncompatibleProfileError, match=match): + ProfileCatalog(profiles, reins).resolve("harness.test@1.0.0") diff --git a/tests/test_rein_aharness.py b/tests/test_rein_aharness.py index 0d5b66c..4b82aa7 100644 --- a/tests/test_rein_aharness.py +++ b/tests/test_rein_aharness.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch import pytest from glas_harness.contract import Rein, SandboxHandle, ToolCall +from glas_harness.profiles import ProfileCatalog from glas_harness.reins.rein_aharness import ReinAharness, ReinAharnessNotInstalled @@ -27,7 +28,7 @@ def test_start_session_writes_task_file_and_captures_head(tmp_path) -> None: rein = ReinAharness() sandbox = SandboxHandle(sandbox_id="abc", host="localhost", reachability={"workspace_dir": str(repo)}) session = rein.start_session( - profile={"id": "harness.agent-dev-local"}, + profile=ProfileCatalog().resolve("harness.agent-dev-local@1.0.0")[0], inputs={"title": "t", "description": "d"}, sandbox=sandbox, ) @@ -47,7 +48,11 @@ def test_start_session_requires_resolvable_target_repo() -> None: def test_dispatch_tool_invokes_agent_harness_cli() -> None: - rein = ReinAharness() + rein = ReinAharness( + model="claude-sonnet-4-6", + tool_profile="green-commit-only", + budget_tokens=1234, + ) session = {"task_file": "/tmp/task.json"} fake_result = MagicMock(returncode=0, stdout="ok", stderr="") @@ -57,6 +62,9 @@ def test_dispatch_tool_invokes_agent_harness_cli() -> None: argv = run.call_args.args[0] assert argv[:3] == ["/usr/bin/agent-harness", "run", "--task-file"] + assert argv[argv.index("--model") + 1] == "claude-sonnet-4-6" + assert argv[argv.index("--tool-profile") + 1] == "green-commit-only" + assert argv[argv.index("--budget-tokens") + 1] == "1234" assert result.ok is True assert result.output == "ok" @@ -88,8 +96,9 @@ def test_end_session_detects_new_commit(tmp_path) -> None: subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "task"], cwd=repo, check=True) summary = rein.end_session({"target_repo": str(repo), "head_before": head_before}) - assert summary["committed"] == "True" - assert summary["commit_sha"] != head_before + assert summary.committed is True + assert summary.outcome == "succeeded" + assert summary.commit_sha != head_before def test_end_session_no_new_commit(tmp_path) -> None: @@ -104,7 +113,8 @@ def test_end_session_no_new_commit(tmp_path) -> None: head = git_head(str(repo)) summary = rein.end_session({"target_repo": str(repo), "head_before": head}) - assert summary["committed"] == "False" + assert summary.committed is False + assert summary.outcome == "failed" def test_dispatch_tool_streams_events_when_enabled() -> None: diff --git a/tests/test_rein_openweights.py b/tests/test_rein_openweights.py index 8c4d927..6ebf716 100644 --- a/tests/test_rein_openweights.py +++ b/tests/test_rein_openweights.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch import pytest from glas_harness.contract import Rein, SandboxHandle, ToolCall +from glas_harness.profiles import ProfileCatalog from glas_harness.reins.rein_openweights import ReinOpenWeights, ReinOpenWeightsNotInstalled @@ -27,7 +28,7 @@ def test_start_session_writes_task_file_and_captures_head(tmp_path) -> None: rein = ReinOpenWeights() sandbox = SandboxHandle(sandbox_id="abc", host="localhost", reachability={"workspace_dir": str(repo)}) session = rein.start_session( - profile={"id": "harness.agent-dev-local"}, + profile=ProfileCatalog().resolve("harness.agent-dev-openweights-local@1.0.0")[0], inputs={"title": "t", "description": "d"}, sandbox=sandbox, ) @@ -63,7 +64,12 @@ def test_dispatch_tool_invokes_rein_openweights_cli() -> None: def test_dispatch_tool_passes_model_when_set() -> None: - rein = ReinOpenWeights(model="meta-llama/llama-3.1-70b-instruct") + rein = ReinOpenWeights( + model="meta-llama/llama-3.1-70b-instruct", + max_turns=8, + budget_tokens=1234, + tool_profile="green-commit-only", + ) session = {"task_file": "/tmp/task.json"} fake_result = MagicMock(returncode=0, stdout="ok", stderr="") @@ -74,6 +80,9 @@ def test_dispatch_tool_passes_model_when_set() -> None: argv = run.call_args.args[0] assert "--model" in argv assert "meta-llama/llama-3.1-70b-instruct" in argv + assert argv[argv.index("--max-turns") + 1] == "8" + assert argv[argv.index("--budget-tokens") + 1] == "1234" + assert argv[argv.index("--tool-profile") + 1] == "green-commit-only" def test_dispatch_tool_reports_failure() -> None: @@ -102,5 +111,6 @@ def test_end_session_detects_new_commit(tmp_path) -> None: subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "task"], cwd=repo, check=True) summary = rein.end_session({"target_repo": str(repo), "head_before": head_before}) - assert summary["committed"] == "True" - assert summary["commit_sha"] != head_before + assert summary.committed is True + assert summary.outcome == "succeeded" + assert summary.commit_sha != head_before diff --git a/workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md b/workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md new file mode 100644 index 0000000..654637a --- /dev/null +++ b/workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md @@ -0,0 +1,316 @@ +--- +id: GLAS-WP-0004 +type: workplan +title: "Versioned execution-constellation profiles and explicit rein routing" +domain: infotech +repo: glas-harness +status: finished +owner: codex +topic_slug: execution-constellation-profiles +created: "2026-08-20" +updated: "2026-08-21" +--- + +# Versioned execution-constellation profiles and explicit rein routing + +## Context + +The accepted family architecture is sound: `glas-harness` owns the harness +contract and outer lifecycle while a concrete rein owns its inner agentic loop. +Both `rein-aharness` and `rein-openweights` implement the contract and have been +live-proven through the gateway. + +The prototype does not yet make that flexibility real for normal consumers: + +- `profiles/*.yaml` name a rein, sandbox profile, and tool profile, but no + production code loads or validates those files; +- `glas-harness run` accepts `--sandbox-profile`, not a Glas harness profile; +- `run_task_through_rein()` silently constructs `ReinAharness` when no rein is + injected; +- both committed profiles select `rein-aharness`, so the profile catalog does + not demonstrate a second execution constellation; +- model selection is a rein-constructor detail (`ReinOpenWeights(model=...)`), + not a versioned, attributable profile input; and +- `SandboxHandle`, `ToolCall`, `ToolResult`, and gateway result/evidence shapes + are implemented but not versioned or declared compatible. + +This workplan turns Glas from a proven adapter seam into the primary, +versioned execution-layer interface for agent workforce assignments and other +consumers. A consumer should be able to select or request an approved Glas +profile without embedding a concrete rein, vendor harness, base model, +sandbox, or tool-policy implementation into its own domain contract. + +## Goal + +Provide a versioned Glas execution request and profile path that resolves an +approved execution constellation: + +```text +consumer execution request + -> glas-harness profile/requirements resolution + -> concrete rein + model route + sandbox profile + tool profile + -> versioned Rein contract + -> normalized execution result and audit evidence +``` + +The decisive proof is one semantically identical safe task executed through +two explicit Glas profiles—one backed by `rein-aharness`, one by +`rein-openweights`—without changing the workforce, role, goal, or task model. + +## Boundaries and invariants + +- `glas-harness` owns execution-profile schema, validation, resolution, rein + registry lookup, sandbox coordination, actor attribution, and gateway + evidence. +- A rein owns its inner agentic loop, backend-specific setup, and credentials, + consistent with ADR-002. +- `llm-connect` and provider adapters retain inference/provider boundaries. + Glas may carry a model route/reference and auditable resolved model, but it + does not become an LLM API, model catalog of record, or secret broker. +- `sand-boxer` owns sandbox provisioning, profiles, placement, and teardown + implementation; Glas selects and consumes a sandbox profile. +- Scheduling and blueprint sourcing remain outside the Glas gateway under + ADR-003. `activity-core`, channels, or rein-local intake supply work; this + workplan standardizes execution after invocation. +- Profiles and evidence contain credential-route references only, never secret + values. +- Unknown, disabled, incompatible, or ambiguous profiles fail closed. No + production invocation silently falls back to `rein-aharness` or a default + model. +- Existing live `rein-aharness` operation remains available throughout the + migration; cutover requires explicit compatibility and rollback evidence. + +## Task: Version and freeze the minimum execution contract + +```task +id: GLAS-WP-0004-T01 +status: done +priority: high +``` + +Define a small versioned contract for the existing `Rein` lifecycle and its +boundary types. Use typed models for at least: + +- execution request and resolved execution context; +- harness profile reference and profile revision/version; +- `SandboxHandle`, `ToolCall`, `ToolResult`, and execution summary; +- optional per-tool events and rein-specific extension metadata; and +- compatibility declaration between a profile, Glas contract version, and rein. + +Document which fields are stable, optional, extensible, and sensitive. Preserve +the existing three-step lifecycle unless an implementation blocker is proven; +do not introduce speculative middleware or move rein internals into Glas. + +**Done when:** the contract has an explicit version, validation tests cover +required/optional/unknown fields and incompatible versions, and both existing +rein adapters satisfy the same typed interface without fabricated evidence. + +## Task: Implement the harness-profile schema, loader, and validator + +```task +id: GLAS-WP-0004-T02 +status: done +priority: high +``` + +Turn `profiles/*.yaml` into a real runtime contract. The minimum profile should +identify: + +- stable profile ID and semantic version; +- compatible Glas contract version; +- rein registry reference and required rein capabilities; +- sandbox profile reference; +- tool-policy/profile reference; +- model route/reference or bounded selection policy, including the resolved + model identifier required in evidence; +- execution limits or references needed at the gateway boundary; and +- credential/policy route references, never values. + +Implement deterministic discovery, parsing, validation, and lookup. Validate +the selected profile against `registry/reins/*.yaml`, including status and +capabilities. Reject duplicate IDs/versions, unknown reins, incompatible +contract versions, unavailable required capabilities, and unsafe inline secret +material. + +**Done when:** committed profiles are loaded by production code, malformed and +incompatible fixtures fail closed with actionable errors, and the profile +catalog has schema and parity tests. + +## Task: Make gateway and channel invocation explicitly profile-driven + +```task +id: GLAS-WP-0004-T03 +status: done +priority: high +``` + +Add `--harness-profile ` to the CLI/channel contract and resolve +the rein, model route, sandbox profile, and tool profile through the loader. +Remove the implicit `ReinAharness()` choice from governed/production paths. + +Keep direct rein injection as a narrow test/library seam if useful. If +`--sandbox-profile` must remain temporarily, classify it as an explicit legacy +or development path, warn clearly, give it a documented removal condition, +and never let it masquerade as profile resolution. + +**Done when:** normal CLI and gateway execution requires an explicit valid +Glas profile, unknown/disabled/ambiguous selections perform no sandbox or rein +side effect, and tests prove that the resolved rein—not a hard-coded default— +receives the session. + +## Task: Add two real execution constellations + +```task +id: GLAS-WP-0004-T04 +status: done +priority: high +``` + +Normalize the existing `rein-aharness` profiles and add at least one committed +`rein-openweights` profile. Make the model and sandbox choices explicit enough +that audit evidence can reconstruct the selected constellation while keeping +provider credentials rein-local. + +Cover both an exact pinned profile reference and the minimum safe form of a +requirements request if implemented. Requirements resolution must be +deterministic, explain why a profile matched, and refuse an ambiguous result; +it must not silently optimize for an undocumented model or cost preference. + +**Done when:** the same safe task fixture can be routed to either rein by +changing only the Glas execution selection, and tests prove that the selected +model route, sandbox profile, and tool profile reach the correct adapter. + +## Task: Normalize execution evidence and failure semantics + +```task +id: GLAS-WP-0004-T05 +status: done +priority: high +``` + +Define one result/audit envelope across reins containing at least: + +- request/correlation ID, actor, project/target, and timestamps; +- Glas contract and profile ID/version; +- resolved rein ID/version, model route/resolved model, sandbox profile and + sandbox ID, and tool profile; +- outcome, refusal/failure class, duration, token/cost fields when known, and + commit/artifact references; and +- optional tool events with explicit availability/completeness semantics. + +Keep unknown distinct from zero and unsupported distinct from empty. Redact +raw prompts, raw model output, secrets, and credential material from the State +Hub event. Ensure sandbox teardown and a compact failure event occur for +resolution, startup, execution, verification, and teardown failures. + +**Done when:** both reins return the common envelope, negative-path tests cover +each lifecycle stage, and evidence identifies the complete execution +constellation without exposing sensitive content. + +## Task: Publish the consumer contract and cross-repository handoffs + +```task +id: GLAS-WP-0004-T06 +status: done +priority: medium +``` + +Document how consumer-owned agent instances, future agentic-resources +`RoleAssignment` records, activity-core work, and channels reference Glas. +Provide a fixture containing at least: + +- `harness_profile_ref` or a versioned execution-requirements object; +- correlation, assignment, role, duty, goal, and resource-envelope references; +- expected output/evidence and execution limits; and +- explicit refusal behavior when the profile or envelope is unavailable. + +Record cross-repository follow-ups as messages, capability requests, or live +work records in their owning repositories rather than implementing scheduling, +workforce allocation, or blueprint compilation inside Glas. + +**Done when:** a consumer can construct a valid execution request without +knowing a rein class or provider CLI, and ownership documentation agrees across +Glas, agentic-resources, KaizenAgentic, activity-core, and the example reins. + +## Task: Compatibility migration and dual-rein live proof + +```task +id: GLAS-WP-0004-T07 +status: done +priority: high +``` + +Provide a staged migration for existing direct `rein-aharness` and +`--sandbox-profile` consumers: + +1. inventory callers and preserve the current live path; +2. add explicit profiles matching current behavior; +3. run compatibility tests and one non-destructive live proof through each + rein using approved credential routes; +4. compare normalized evidence and verify sandbox cleanup; +5. migrate callers individually with rollback instructions; and +6. remove the implicit rein default only after telemetry or repository search + proves no governed caller depends on it. + +No live provider credential may be printed, committed, or copied into State +Hub. Use credential routing before requesting access. + +**2026-08-20 evidence:** the same bounded fixture was dispatched through both +explicit profiles. `rein-aharness` succeeded with a real commit and complete +tool-event evidence. `rein-openweights` resolved, ran, returned normalized +evidence, and tore down its sandbox, but the existing rein-local OpenRouter +lane was rejected by the provider with HTTP 401 before a model turn. Live +open-weight success waits on the owner repairing/rotating catalog route +`rein-openweights-openrouter-approle`; no sibling workload credential will be +borrowed. See `docs/evidence/GLAS-WP-0004-live-proof-2026-08-20.md`. + +**Completed 2026-08-21:** after the operator repaired the key, the profile +completed the same fixture in three turns with 3,497 tokens and commit +`b0600b25066731c6e1fc458409429f76a844f959`; sandbox `220482bc` was verified +destroyed. The check also corrected the rein's documented-but-missing default +AppRole directory. Both real backend success criteria and rollback evidence are +now met. + +**Done when:** both real backends complete the same bounded acceptance task +through explicit Glas profiles, current `rein-aharness` service behavior has +not regressed, rollback is documented, and the implicit production default is +retired or has a metered retirement record with a named owner. + +## Task: Refresh architecture, operations, and release evidence + +```task +id: GLAS-WP-0004-T08 +status: done +priority: medium +``` + +Update `INTENT.md`, `SCOPE.md`, the harness/channel contracts, registry docs, +CLI help, and operational examples to describe the implemented profile-driven +path. Correct stale current-state claims, including gateway State Hub reporting +that is already implemented. Add CI coverage for profile/catalog validation +and record the compatibility/versioning policy. + +Run the full Glas suite plus the contract tests in both rein repositories. +Record non-secret live evidence and State Hub progress. Before marking the +workplan finished, hand off every remaining actionable item as a live work +record rather than leaving it only in prose. + +**Done when:** documentation matches runtime behavior, tests and live proofs +pass, generated work records are synchronized, and the workplan has no +untracked residual implementation gap. + +## Overall acceptance + +This workplan is complete only when: + +1. Glas profiles are runtime inputs, not unused YAML documentation. +2. Governed execution never silently defaults to `rein-aharness` or a model. +3. A consumer references Glas rather than importing or naming a concrete rein. +4. Two explicit profiles run the same safe task through different rein/model + constellations with a common evidence envelope. +5. Profile, rein, model, sandbox, tool policy, actor, and contract versions are + reconstructable from non-secret evidence. +6. Scheduling, blueprint ownership, provider credentials, sandbox provisioning, + and workforce allocation remain with their declared owners. +7. Existing live `rein-aharness` operation has a tested compatibility and + rollback path.