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

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