feat: add versioned execution profiles
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

This commit is contained in:
tegwick 2026-08-21 00:21:53 +02:00
parent 641e85f5a8
commit 1cd890d871
34 changed files with 2087 additions and 471 deletions

View file

@ -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 <path> --title "..." --description "..."
```

View file

@ -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.**
proves correctness. snuggle-inventor invents code. **glas-harness runs the agent.**

View file

@ -1 +1,57 @@
Agent harness platform and meta framework.
# 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
```

View file

@ -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/`

View file

@ -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 |

View file

@ -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 <id[@version]>`; 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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

38
docs/intakes/residuals.md Normal file
View file

@ -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.
```

View file

@ -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
}

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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"]

View file

@ -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`).

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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."""

View file

@ -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,
)

View file

@ -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))
]

View file

@ -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 {}

View file

@ -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"),
},
)

View file

@ -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,
},
)

21
tests/test_cli.py Normal file
View file

@ -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"

View file

@ -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",

View file

@ -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"

164
tests/test_profiles.py Normal file
View file

@ -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")

View file

@ -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:

View file

@ -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

View file

@ -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 <id[@version]>` 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.