agentic-runner 3.3.0__tar.gz → 3.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/PKG-INFO +1 -1
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/__init__.py +1 -1
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/activities.py +115 -1
- agentic_runner-3.4.1/src/agentic_runner/cli_floor.py +33 -0
- agentic_runner-3.4.1/src/agentic_runner/harness_self_test.py +162 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/service.py +81 -16
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/agent_runtime.py +20 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/claude_runtime.py +12 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/codex_runtime.py +16 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/contract_isolation.py +64 -3
- agentic_runner-3.4.1/src/agentic_runner/workers/harness_outcome.py +213 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workstation.py +4 -20
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/.gitignore +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/LICENSE +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/README.md +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/pyproject.toml +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/attempts.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/auth_mode.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/build.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/callback.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/child_watcher.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/cli.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/config.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/credentials.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/device_login_activities.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/egress.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/heartbeat_link.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/hooks.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/host_store.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/__init__.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/git/__init__.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/git/contracts.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/git/evidence.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/git/workspace.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/github/__init__.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/github/auth.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/github/fake_client.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/github/gh_client.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/lifecycle.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/llm_proxy.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/mcp.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/message_store.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/private_state.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/py.typed +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/recipient_key_secret.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/registration.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/runtime/__init__.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/runtime/verifier_command.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/sealed_box.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/testing/__init__.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/testing/__main__.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/testing/control_plane.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/testing/plugin.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/testing/test_conformance.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/testing/workflow.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/tiny_http.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/triage_activities.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/user_sources.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/__init__.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/_runtime_support.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/command_policy.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/contract_device_login.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/fastapi_client.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/harness_usage.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/mcp_config.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/settings.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/skills.py +0 -0
|
@@ -31,6 +31,7 @@ import tempfile
|
|
|
31
31
|
import time
|
|
32
32
|
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
|
33
33
|
from dataclasses import asdict, dataclass, field, fields, replace
|
|
34
|
+
from datetime import UTC, datetime
|
|
34
35
|
from pathlib import Path
|
|
35
36
|
from typing import Any, NoReturn, Protocol
|
|
36
37
|
from uuid import UUID
|
|
@@ -75,6 +76,7 @@ from agentic_runner.credentials import (
|
|
|
75
76
|
UnresolvableCredentialReferenceError,
|
|
76
77
|
)
|
|
77
78
|
from agentic_runner.egress import EGRESS_REFUSED_SOURCE, EgressProxy, Resolver, host_of
|
|
79
|
+
from agentic_runner.harness_self_test import DirectivesInFlight
|
|
78
80
|
from agentic_runner.heartbeat_link import HeartbeatLink, SeamUnavailableError
|
|
79
81
|
from agentic_runner.hooks import (
|
|
80
82
|
HOOK_EVIDENCE_SOURCE,
|
|
@@ -140,6 +142,7 @@ from agentic_runner.workers.fastapi_client import (
|
|
|
140
142
|
WorkerFastApiClientError,
|
|
141
143
|
directive_token,
|
|
142
144
|
)
|
|
145
|
+
from agentic_runner.workers.harness_outcome import classify_harness_outcome
|
|
143
146
|
from agentic_runner.workers.harness_usage import extract_claude_result, extract_codex_turn_usage
|
|
144
147
|
from agentic_runner.workers.mcp_config import McpServerEntry
|
|
145
148
|
from agentic_runner.workers.skills import (
|
|
@@ -168,6 +171,7 @@ from agentic_runner_contracts.activity_io import (
|
|
|
168
171
|
FixDirectiveOutput,
|
|
169
172
|
GitHubCallError,
|
|
170
173
|
GitHubCallFailure,
|
|
174
|
+
HarnessHold,
|
|
171
175
|
LearningDirectiveInput,
|
|
172
176
|
LearningDirectiveOutput,
|
|
173
177
|
MemberDirectiveInput,
|
|
@@ -1442,6 +1446,7 @@ DIRECTIVE_TOKEN_EVIDENCE_SOURCE = "runner.directive_token"
|
|
|
1442
1446
|
AGENT_RUNTIME_EVIDENCE_SOURCE = "runner.agent_runtime"
|
|
1443
1447
|
SKILLS_EVIDENCE_SOURCE = "runner.skills"
|
|
1444
1448
|
HARNESS_LOGIN_RESIDUE_SOURCE = "runner.harness_login_residue"
|
|
1449
|
+
HARNESS_HOLD_SOURCE = "runner.harness_hold"
|
|
1445
1450
|
|
|
1446
1451
|
|
|
1447
1452
|
class _EvidenceWriter(Protocol):
|
|
@@ -1555,8 +1560,11 @@ class RunnerRalphActivities:
|
|
|
1555
1560
|
egress_resolver: Resolver | None = None,
|
|
1556
1561
|
tool_server_health: ToolServerHealthLog | None = None,
|
|
1557
1562
|
attempt_records: AttemptRecords | None = None,
|
|
1563
|
+
directives_in_flight: DirectivesInFlight | None = None,
|
|
1558
1564
|
) -> None:
|
|
1559
1565
|
self._fastapi_client = fastapi_client
|
|
1566
|
+
# Read by the harness self-test, which never runs beside a Contract's Directive.
|
|
1567
|
+
self._directives_in_flight = directives_in_flight or DirectivesInFlight()
|
|
1560
1568
|
# Wall-clock source for per-Directive runtime, accrued against the Budget's
|
|
1561
1569
|
# wall-clock dimension (ADR-0007). Activities run outside the Temporal sandbox,
|
|
1562
1570
|
# so a real monotonic clock is allowed here (it must never be used in the
|
|
@@ -2018,6 +2026,31 @@ class RunnerRalphActivities:
|
|
|
2018
2026
|
auth_mode=attempt.auth_mode,
|
|
2019
2027
|
)
|
|
2020
2028
|
plan = _take_plan(checkout_workspace)
|
|
2029
|
+
harness_hold = await self._harness_hold(
|
|
2030
|
+
runtime_state=runtime_state,
|
|
2031
|
+
work_record_id=request.work_record_id,
|
|
2032
|
+
directive_number=request.directive_number,
|
|
2033
|
+
result=codex_result,
|
|
2034
|
+
auth_mode=attempt.auth_mode,
|
|
2035
|
+
)
|
|
2036
|
+
if harness_hold is not None:
|
|
2037
|
+
return BranchPullRequestOutput(
|
|
2038
|
+
repository=request.repository,
|
|
2039
|
+
branch_name=request.branch_name,
|
|
2040
|
+
base_ref=request.base_ref,
|
|
2041
|
+
pr_number=0,
|
|
2042
|
+
pr_url="",
|
|
2043
|
+
branch_created=branch.created,
|
|
2044
|
+
pr_created=False,
|
|
2045
|
+
duration_seconds=self._monotonic() - directive_started,
|
|
2046
|
+
usage=await self._directive_usage(
|
|
2047
|
+
work_record_id=request.work_record_id,
|
|
2048
|
+
directive_number=request.directive_number,
|
|
2049
|
+
),
|
|
2050
|
+
workspace_path=str(workspace_path),
|
|
2051
|
+
grant_snapshot=dict(request.grant_snapshot),
|
|
2052
|
+
harness_hold=harness_hold,
|
|
2053
|
+
)
|
|
2021
2054
|
if codex_result.exit_code != 0:
|
|
2022
2055
|
if _is_guard_mode_refusal(codex_result):
|
|
2023
2056
|
# The runtime refused this Directive (guard-mode). Return a flagged
|
|
@@ -2475,6 +2508,7 @@ class RunnerRalphActivities:
|
|
|
2475
2508
|
scratch_root = sandbox.tmp_dir if sandbox is not None else Path(tempfile.gettempdir())
|
|
2476
2509
|
attempt_dir = prepare_attempt_dir(scratch_root / f"attempt-{key}", uid)
|
|
2477
2510
|
async with contextlib.AsyncExitStack() as stack:
|
|
2511
|
+
stack.enter_context(self._directives_in_flight.running(facts.contract_id))
|
|
2478
2512
|
stack.callback(shutil.rmtree, attempt_dir, ignore_errors=True)
|
|
2479
2513
|
callback_env: dict[str, str] = {}
|
|
2480
2514
|
if self._socket_dir is not None:
|
|
@@ -4484,7 +4518,13 @@ class RunnerRalphActivities:
|
|
|
4484
4518
|
guard_mode_refused=True,
|
|
4485
4519
|
workspace_path=request.workspace_path,
|
|
4486
4520
|
)
|
|
4487
|
-
if
|
|
4521
|
+
if (
|
|
4522
|
+
consumed
|
|
4523
|
+
and self._message_store is not None
|
|
4524
|
+
and not ran.guard_mode_refused
|
|
4525
|
+
# A held turn never read its Messages; the wake after the hold must see them.
|
|
4526
|
+
and ran.harness_hold is None
|
|
4527
|
+
):
|
|
4488
4528
|
self._message_store.mark_consumed(
|
|
4489
4529
|
contract_id=contract[0],
|
|
4490
4530
|
work_record_id=request.work_record_id,
|
|
@@ -4616,6 +4656,13 @@ class RunnerRalphActivities:
|
|
|
4616
4656
|
result=result,
|
|
4617
4657
|
auth_mode=attempt.auth_mode,
|
|
4618
4658
|
)
|
|
4659
|
+
harness_hold = await self._harness_hold(
|
|
4660
|
+
runtime_state=state,
|
|
4661
|
+
work_record_id=request.work_record_id,
|
|
4662
|
+
directive_number=request.directive_number,
|
|
4663
|
+
result=result,
|
|
4664
|
+
auth_mode=attempt.auth_mode,
|
|
4665
|
+
)
|
|
4619
4666
|
usage = await self._directive_usage(
|
|
4620
4667
|
work_record_id=request.work_record_id,
|
|
4621
4668
|
directive_number=request.directive_number,
|
|
@@ -4652,6 +4699,7 @@ class RunnerRalphActivities:
|
|
|
4652
4699
|
usage=usage,
|
|
4653
4700
|
outcome=LEARNING_FAILED,
|
|
4654
4701
|
summary=f"the Learner's runtime exited {result.exit_code}",
|
|
4702
|
+
harness_hold=harness_hold,
|
|
4655
4703
|
)
|
|
4656
4704
|
return self._learning_output(
|
|
4657
4705
|
request,
|
|
@@ -4668,6 +4716,7 @@ class RunnerRalphActivities:
|
|
|
4668
4716
|
usage: DirectiveUsage | None = None,
|
|
4669
4717
|
outcome: str = LEARNING_PROPOSED,
|
|
4670
4718
|
summary: str = "",
|
|
4719
|
+
harness_hold: HarnessHold | None = None,
|
|
4671
4720
|
) -> LearningDirectiveOutput:
|
|
4672
4721
|
return LearningDirectiveOutput(
|
|
4673
4722
|
work_record_id=request.work_record_id,
|
|
@@ -4676,6 +4725,7 @@ class RunnerRalphActivities:
|
|
|
4676
4725
|
usage=usage or DirectiveUsage(),
|
|
4677
4726
|
outcome=outcome,
|
|
4678
4727
|
summary=summary,
|
|
4728
|
+
harness_hold=harness_hold,
|
|
4679
4729
|
)
|
|
4680
4730
|
|
|
4681
4731
|
def _learner_transcript(self, state: _RuntimeContextState, work_record_id: str) -> str:
|
|
@@ -4875,6 +4925,30 @@ class RunnerRalphActivities:
|
|
|
4875
4925
|
)
|
|
4876
4926
|
# Taken before the commit below so it never lands on the branch (PRD 59).
|
|
4877
4927
|
plan = _take_plan(state.workspace_path)
|
|
4928
|
+
harness_hold = await self._harness_hold(
|
|
4929
|
+
runtime_state=state,
|
|
4930
|
+
work_record_id=request.work_record_id,
|
|
4931
|
+
directive_number=request.directive_number,
|
|
4932
|
+
result=codex_result,
|
|
4933
|
+
auth_mode=attempt.auth_mode,
|
|
4934
|
+
)
|
|
4935
|
+
if harness_hold is not None:
|
|
4936
|
+
return FixDirectiveOutput(
|
|
4937
|
+
work_record_id=request.work_record_id,
|
|
4938
|
+
repository=request.repository,
|
|
4939
|
+
pr_number=request.pr_number,
|
|
4940
|
+
directive_number=request.directive_number,
|
|
4941
|
+
branch_head_sha="",
|
|
4942
|
+
summary=f"held: the harness reported {harness_hold.kind.value}",
|
|
4943
|
+
duration_seconds=self._monotonic() - directive_started,
|
|
4944
|
+
usage=await self._directive_usage(
|
|
4945
|
+
work_record_id=request.work_record_id,
|
|
4946
|
+
directive_number=request.directive_number,
|
|
4947
|
+
),
|
|
4948
|
+
workspace_path=str(state.workspace_path),
|
|
4949
|
+
grant_snapshot=state.grant_snapshot_payload,
|
|
4950
|
+
harness_hold=harness_hold,
|
|
4951
|
+
)
|
|
4878
4952
|
if codex_result.exit_code != 0:
|
|
4879
4953
|
if _is_guard_mode_refusal(codex_result):
|
|
4880
4954
|
return FixDirectiveOutput(
|
|
@@ -5114,6 +5188,46 @@ class RunnerRalphActivities:
|
|
|
5114
5188
|
}
|
|
5115
5189
|
)
|
|
5116
5190
|
|
|
5191
|
+
async def _harness_hold(
|
|
5192
|
+
self,
|
|
5193
|
+
*,
|
|
5194
|
+
runtime_state: _RuntimeContextState,
|
|
5195
|
+
work_record_id: str,
|
|
5196
|
+
directive_number: int,
|
|
5197
|
+
result: DirectiveResult,
|
|
5198
|
+
auth_mode: AuthMode,
|
|
5199
|
+
) -> HarnessHold | None:
|
|
5200
|
+
"""A subscription harness that stopped on its usage limit or sign-in, recorded
|
|
5201
|
+
once (local-agents 08). An ``api_key`` Directive's limits are the proxy's, so it is
|
|
5202
|
+
never classified. The Evidence names the kind and the reset, never the output."""
|
|
5203
|
+
|
|
5204
|
+
if auth_mode != AuthMode.SUBSCRIPTION:
|
|
5205
|
+
return None
|
|
5206
|
+
hold = classify_harness_outcome(
|
|
5207
|
+
runtime_state.cli_kind,
|
|
5208
|
+
exit_code=result.exit_code,
|
|
5209
|
+
stdout=result.stdout,
|
|
5210
|
+
stderr=result.stderr,
|
|
5211
|
+
error=result.error,
|
|
5212
|
+
now=datetime.now(UTC),
|
|
5213
|
+
)
|
|
5214
|
+
if hold is None:
|
|
5215
|
+
return None
|
|
5216
|
+
await self._fastapi_client.append_evidence(
|
|
5217
|
+
work_record_id,
|
|
5218
|
+
source=HARNESS_HOLD_SOURCE,
|
|
5219
|
+
payload={
|
|
5220
|
+
"event": "directive.harness_hold",
|
|
5221
|
+
"kind": hold.kind.value,
|
|
5222
|
+
"cli_kind": runtime_state.cli_kind,
|
|
5223
|
+
"retry_not_before": hold.retry_not_before,
|
|
5224
|
+
"directive_number": directive_number,
|
|
5225
|
+
"agent_id": runtime_state.agent_id,
|
|
5226
|
+
"contract_id": runtime_state.contract_id,
|
|
5227
|
+
},
|
|
5228
|
+
)
|
|
5229
|
+
return hold
|
|
5230
|
+
|
|
5117
5231
|
@activity.defn(name="wipe_contract_residue")
|
|
5118
5232
|
async def wipe_contract_residue(self, request: ContractResidueInput) -> ContractResidueOutput:
|
|
5119
5233
|
"""Delete everything one Contract left on this Runner and retire its uid (17 A6).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Which executable each Agent Runtime CLI is, and the version floor it must clear."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
__all__ = ["CLI_FLOORS", "CLI_NAMES", "meets_floor", "parse_cli_version"]
|
|
10
|
+
|
|
11
|
+
# What the Profile's `cli_kind` runs, by executable name on PATH.
|
|
12
|
+
CLI_NAMES: Final[Mapping[str, str]] = {"codex_cli": "codex", "claude_code": "claude"}
|
|
13
|
+
# The versions the Runner image is built and tested against (Dockerfile.runner's
|
|
14
|
+
# CODEX_CLI_VERSION / CLAUDE_CODE_VERSION; a test holds the two together). Below the
|
|
15
|
+
# floor is *reported*, not refused: the user's own CLI is the user's to upgrade.
|
|
16
|
+
CLI_FLOORS: Final[Mapping[str, str]] = {"codex": "0.141.0", "claude": "2.1.280"}
|
|
17
|
+
|
|
18
|
+
_VERSION = re.compile(r"(\d+\.\d+\.\d+[0-9A-Za-z.+-]{0,40})")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_cli_version(output: str) -> str | None:
|
|
22
|
+
match = _VERSION.search(output)
|
|
23
|
+
return None if match is None else match.group(1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def meets_floor(name: str, version: str) -> bool:
|
|
27
|
+
"""Whether ``version`` of the executable ``name`` clears the Runner's floor for it."""
|
|
28
|
+
|
|
29
|
+
return _version_tuple(version) >= _version_tuple(CLI_FLOORS[name])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _version_tuple(version: str) -> tuple[int, ...]:
|
|
33
|
+
return tuple(int(part) for part in re.findall(r"\d+", version)[:3])
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""A harness's ``--version``, run as each Contract would run it (local-agents 17).
|
|
2
|
+
|
|
3
|
+
What the heartbeat's ``cli`` attestation says is that the Runner's own uid can start a
|
|
4
|
+
CLI. Whether a Contract can is a different fact: its uid, its harness root and its
|
|
5
|
+
sandbox floor all stand between the two. So the self-test spawns exactly what a
|
|
6
|
+
Directive spawns, minus the prompt, and keeps only the verdict -- never the output, which
|
|
7
|
+
on a harness root can carry an account name.
|
|
8
|
+
|
|
9
|
+
At most once per interval per Contract and never while that Contract has a Directive
|
|
10
|
+
running: the harness root is the Directive's while it runs, and a concurrent spawn
|
|
11
|
+
under the same rlimit floor would count against the Directive's process ceiling.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
from collections import Counter
|
|
19
|
+
from collections.abc import Awaitable, Callable, Collection, Iterator, Sequence
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from typing import Final
|
|
23
|
+
from uuid import UUID
|
|
24
|
+
|
|
25
|
+
from agentic_runner.cli_floor import CLI_NAMES, meets_floor, parse_cli_version
|
|
26
|
+
from agentic_runner.workers._runtime_support import SubprocessResult, run_subprocess_exec
|
|
27
|
+
from agentic_runner.workers.contract_isolation import ContractIsolationError, DirectiveSandbox
|
|
28
|
+
from agentic_runner_contracts.runner_registration import HarnessSelfTest, SelfTestFailure
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"SELF_TEST_INTERVAL_SECONDS",
|
|
32
|
+
"DirectivesInFlight",
|
|
33
|
+
"HarnessSelfTests",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
SELF_TEST_INTERVAL_SECONDS: Final = 15 * 60
|
|
37
|
+
_TIMEOUT_SECONDS: Final = 10
|
|
38
|
+
_OUTPUT_LIMIT_BYTES: Final = 4096
|
|
39
|
+
|
|
40
|
+
# Where each harness reads its config root from; the same variable its runtime sets.
|
|
41
|
+
_HARNESS_ROOT_ENV: Final = {"codex_cli": "CODEX_HOME", "claude_code": "CLAUDE_CONFIG_DIR"}
|
|
42
|
+
|
|
43
|
+
SandboxFor = Callable[[str, str], DirectiveSandbox | None]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DirectivesInFlight:
|
|
47
|
+
"""Which Contracts have a Directive running in this process right now."""
|
|
48
|
+
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
self._running: Counter[str] = Counter()
|
|
51
|
+
|
|
52
|
+
@contextmanager
|
|
53
|
+
def running(self, contract_id: str | None) -> Iterator[None]:
|
|
54
|
+
key = contract_id or ""
|
|
55
|
+
self._running[key] += 1
|
|
56
|
+
try:
|
|
57
|
+
yield
|
|
58
|
+
finally:
|
|
59
|
+
self._running[key] -= 1
|
|
60
|
+
if self._running[key] <= 0:
|
|
61
|
+
del self._running[key]
|
|
62
|
+
|
|
63
|
+
def busy(self, contract_id: str) -> bool:
|
|
64
|
+
return self._running[contract_id] > 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class HarnessSelfTests:
|
|
68
|
+
"""The latest self-test per ``(contract_id, cli_kind)``, and when the next one is due."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
sandbox_for: SandboxFor,
|
|
74
|
+
in_flight: DirectivesInFlight,
|
|
75
|
+
served: Collection[str],
|
|
76
|
+
run: Callable[..., Awaitable[SubprocessResult]] = run_subprocess_exec,
|
|
77
|
+
monotonic: Callable[[], float],
|
|
78
|
+
clock: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
79
|
+
interval_seconds: float = SELF_TEST_INTERVAL_SECONDS,
|
|
80
|
+
) -> None:
|
|
81
|
+
self._sandbox_for = sandbox_for
|
|
82
|
+
self._in_flight = in_flight
|
|
83
|
+
self._served = frozenset(served)
|
|
84
|
+
self._run = run
|
|
85
|
+
self._monotonic = monotonic
|
|
86
|
+
self._clock = clock
|
|
87
|
+
self._interval = interval_seconds
|
|
88
|
+
self._results: dict[tuple[str, str], HarnessSelfTest] = {}
|
|
89
|
+
self._last_run: dict[tuple[str, str], float] = {}
|
|
90
|
+
|
|
91
|
+
def result(self, contract_id: UUID | str, cli_kind: str) -> HarnessSelfTest | None:
|
|
92
|
+
return self._results.get((str(contract_id), cli_kind))
|
|
93
|
+
|
|
94
|
+
async def run_due(self, targets: Sequence[tuple[str, str]]) -> None:
|
|
95
|
+
for gone in (self._results.keys() | self._last_run.keys()) - set(targets):
|
|
96
|
+
self._forget(gone)
|
|
97
|
+
for contract_id, cli_kind in targets:
|
|
98
|
+
key = (contract_id, cli_kind)
|
|
99
|
+
last = self._last_run.get(key)
|
|
100
|
+
if last is not None and self._monotonic() - last < self._interval:
|
|
101
|
+
continue
|
|
102
|
+
# Checked before the spawn only: a Directive that starts during the (at most
|
|
103
|
+
# _TIMEOUT_SECONDS) spawn overlaps it. Accepted -- `--version` neither reads
|
|
104
|
+
# nor writes the harness root's state and holds one process slot briefly.
|
|
105
|
+
if self._in_flight.busy(contract_id):
|
|
106
|
+
# Not stamped: it runs on the first sweep after the Directive ends.
|
|
107
|
+
continue
|
|
108
|
+
result = await self._self_test(contract_id, cli_kind)
|
|
109
|
+
if result is None:
|
|
110
|
+
self._forget(key)
|
|
111
|
+
continue
|
|
112
|
+
self._results[key] = result
|
|
113
|
+
self._last_run[key] = self._monotonic()
|
|
114
|
+
|
|
115
|
+
def _forget(self, key: tuple[str, str]) -> None:
|
|
116
|
+
self._results.pop(key, None)
|
|
117
|
+
self._last_run.pop(key, None)
|
|
118
|
+
|
|
119
|
+
async def _self_test(self, contract_id: str, cli_kind: str) -> HarnessSelfTest | None:
|
|
120
|
+
"""``None`` when the harness root is gone, wiped since the sweep listed it."""
|
|
121
|
+
|
|
122
|
+
name = CLI_NAMES.get(cli_kind)
|
|
123
|
+
if name is None or cli_kind not in self._served:
|
|
124
|
+
return self._failed(SelfTestFailure.NOT_FOUND)
|
|
125
|
+
try:
|
|
126
|
+
sandbox = self._sandbox_for(contract_id, cli_kind)
|
|
127
|
+
except (ContractIsolationError, OSError):
|
|
128
|
+
return self._failed(SelfTestFailure.SPAWN_FAILED)
|
|
129
|
+
if sandbox is None:
|
|
130
|
+
return None
|
|
131
|
+
env = {
|
|
132
|
+
"PATH": os.environ.get("PATH") or os.defpath,
|
|
133
|
+
"HOME": str(sandbox.home_dir),
|
|
134
|
+
"TMPDIR": str(sandbox.tmp_dir),
|
|
135
|
+
_HARNESS_ROOT_ENV[cli_kind]: str(sandbox.harness_config_dir),
|
|
136
|
+
}
|
|
137
|
+
try:
|
|
138
|
+
answer = await self._run(
|
|
139
|
+
argv=[name, "--version"],
|
|
140
|
+
cwd=sandbox.home_dir,
|
|
141
|
+
env=env,
|
|
142
|
+
stdin=None,
|
|
143
|
+
timeout_seconds=_TIMEOUT_SECONDS,
|
|
144
|
+
output_limit_bytes=_OUTPUT_LIMIT_BYTES,
|
|
145
|
+
sandbox=sandbox,
|
|
146
|
+
)
|
|
147
|
+
except TimeoutError:
|
|
148
|
+
return self._failed(SelfTestFailure.TIMEOUT)
|
|
149
|
+
except FileNotFoundError:
|
|
150
|
+
return self._failed(SelfTestFailure.NOT_FOUND)
|
|
151
|
+
except (OSError, subprocess.SubprocessError):
|
|
152
|
+
# SubprocessError: the sandbox floor's own preexec hook refused the spawn.
|
|
153
|
+
return self._failed(SelfTestFailure.SPAWN_FAILED)
|
|
154
|
+
version = parse_cli_version(answer.stdout or answer.stderr)
|
|
155
|
+
if answer.exit_code != 0 or version is None:
|
|
156
|
+
return self._failed(SelfTestFailure.SPAWN_FAILED)
|
|
157
|
+
if not meets_floor(name, version):
|
|
158
|
+
return self._failed(SelfTestFailure.BELOW_FLOOR)
|
|
159
|
+
return HarnessSelfTest(ok=True, at=self._clock())
|
|
160
|
+
|
|
161
|
+
def _failed(self, reason: SelfTestFailure) -> HarnessSelfTest:
|
|
162
|
+
return HarnessSelfTest(ok=False, at=self._clock(), reason=reason)
|
|
@@ -56,6 +56,7 @@ from agentic_runner.credentials import (
|
|
|
56
56
|
HostCredentialStore,
|
|
57
57
|
)
|
|
58
58
|
from agentic_runner.device_login_activities import ContractDeviceLoginActivities
|
|
59
|
+
from agentic_runner.harness_self_test import DirectivesInFlight, HarnessSelfTests
|
|
59
60
|
from agentic_runner.heartbeat_link import WAKE_DIVERGENCE, HeartbeatLink
|
|
60
61
|
from agentic_runner.hooks import AttemptFacts, HookName, HookRunner
|
|
61
62
|
from agentic_runner.integrations.git.workspace import LocalGitWorkspace
|
|
@@ -168,6 +169,8 @@ TEMPORAL_TLS_ENV = "AGENTIC_TEMPORAL_TLS"
|
|
|
168
169
|
# glances at their inbox; anything tighter spends IMAP logins, not attention.
|
|
169
170
|
SOURCE_POLL_SECONDS_ENV = "AGENTIC_RUNNER_SOURCE_POLL_SECONDS"
|
|
170
171
|
DEFAULT_SOURCE_POLL_SECONDS = 60.0
|
|
172
|
+
# How often due harness self-tests are looked for; each is due every 15 minutes at most.
|
|
173
|
+
SELF_TEST_SWEEP_SECONDS = 60.0
|
|
171
174
|
# The Recipient Key is normally *generated here*, once per installation, and kept in the
|
|
172
175
|
# state directory (PRD issue 48, 22 A4). These two exist for an installer that mints it
|
|
173
176
|
# outside this process and are read only when both are set; the Helm chart takes the
|
|
@@ -558,6 +561,11 @@ class ControlPlaneStream:
|
|
|
558
561
|
messages: MessageStore | None = None
|
|
559
562
|
# Console-v2 issue 29: the latest start of each Runner-hosted Tool Server.
|
|
560
563
|
tool_servers: ToolServerHealthLog | None = None
|
|
564
|
+
# Local-agents 17: each runtime's descriptor, the harness roots on disk and the last
|
|
565
|
+
# self-test under each.
|
|
566
|
+
runtimes: Mapping[str, AgentRuntime] = field(default_factory=dict)
|
|
567
|
+
contracts: ContractIsolation | None = None
|
|
568
|
+
self_tests: HarnessSelfTests | None = None
|
|
561
569
|
# Local-agents 04b: which delivered references fill the LLM proxy's slot, and where.
|
|
562
570
|
llm_slots: Mapping[str, LlmProvider] = field(default_factory=lambda: dict(LLM_SLOT_REFERENCES))
|
|
563
571
|
_delivered: set[tuple[str, str]] = field(default_factory=set)
|
|
@@ -589,21 +597,44 @@ class ControlPlaneStream:
|
|
|
589
597
|
def _harnesses(self) -> list[HarnessVersion]:
|
|
590
598
|
"""Per Contract: the CLI's version and whether it runs on a delivered key (22 A8)
|
|
591
599
|
or on the login its funder created in the harness root -- read off the slot, never
|
|
592
|
-
off the root, which the Runner does not open.
|
|
600
|
+
off the root, which the Runner does not open. A Contract with a harness root and no
|
|
601
|
+
slot is listed too, so a login-only Contract gets its descriptor (local-agents 17).
|
|
602
|
+
"""
|
|
593
603
|
|
|
594
604
|
if self.attestation is None:
|
|
595
605
|
return []
|
|
596
606
|
versions = {cli.cli_kind: cli.version for cli in self.attestation.clis}
|
|
607
|
+
slots = {
|
|
608
|
+
(str(slot.contract_id), slot.runtime_kind): slot.present
|
|
609
|
+
for slot in self.proxy.slots.statuses()
|
|
610
|
+
}
|
|
611
|
+
roots = self.contracts.harness_roots() if self.contracts is not None else []
|
|
597
612
|
return [
|
|
598
|
-
|
|
599
|
-
contract_id=
|
|
600
|
-
cli_kind=slot.runtime_kind,
|
|
601
|
-
version=versions.get(slot.runtime_kind, "unknown"),
|
|
602
|
-
auth_mode="api_key" if slot.present else "login",
|
|
613
|
+
self._harness(
|
|
614
|
+
contract_id, cli_kind, versions, delivered=slots.get((contract_id, cli_kind), False)
|
|
603
615
|
)
|
|
604
|
-
for
|
|
616
|
+
for contract_id, cli_kind in sorted(set(slots) | set(roots))
|
|
605
617
|
]
|
|
606
618
|
|
|
619
|
+
def _harness(
|
|
620
|
+
self, contract_id: str, cli_kind: str, versions: Mapping[str, str], *, delivered: bool
|
|
621
|
+
) -> HarnessVersion:
|
|
622
|
+
runtime = self.runtimes.get(cli_kind)
|
|
623
|
+
capabilities = runtime.capabilities() if runtime is not None else None
|
|
624
|
+
return HarnessVersion(
|
|
625
|
+
contract_id=UUID(contract_id),
|
|
626
|
+
cli_kind=cli_kind,
|
|
627
|
+
version=versions.get(cli_kind, "unknown"),
|
|
628
|
+
auth_mode="api_key" if delivered else "login",
|
|
629
|
+
auth_modes=(sorted(capabilities.auth_modes) if capabilities is not None else None),
|
|
630
|
+
permission_mode=capabilities.permission_mode if capabilities is not None else None,
|
|
631
|
+
self_test=(
|
|
632
|
+
self.self_tests.result(contract_id, cli_kind)
|
|
633
|
+
if self.self_tests is not None
|
|
634
|
+
else None
|
|
635
|
+
),
|
|
636
|
+
)
|
|
637
|
+
|
|
607
638
|
async def directive_token(self, directive_id: str) -> str:
|
|
608
639
|
"""One activity execution's control-plane token, by Directive id (PRD issue 63)."""
|
|
609
640
|
|
|
@@ -929,6 +960,17 @@ async def _serve_registered(
|
|
|
929
960
|
load = _LoadInterceptor()
|
|
930
961
|
proxy = LlmProxy(slots=SlotStore(), ceilings=CeilingStore(), outbox=UsageOutbox())
|
|
931
962
|
credentials = build_credential_resolver(config, host_store=host_store)
|
|
963
|
+
isolation = build_contract_isolation(settings, can_separate_uids=can_change_uid)
|
|
964
|
+
runtimes = build_agent_runtimes(settings, attestation.clis if attestation is not None else [])
|
|
965
|
+
in_flight = DirectivesInFlight()
|
|
966
|
+
self_tests = HarnessSelfTests(
|
|
967
|
+
sandbox_for=lambda contract_id, cli_kind: isolation.existing_sandbox(
|
|
968
|
+
contract_id, runtime_kind=cli_kind
|
|
969
|
+
),
|
|
970
|
+
in_flight=in_flight,
|
|
971
|
+
served=runtimes,
|
|
972
|
+
monotonic=time.monotonic,
|
|
973
|
+
)
|
|
932
974
|
stream = ControlPlaneStream(
|
|
933
975
|
client=RunnerRegistrationClient(base_url=control_plane, client=http_client),
|
|
934
976
|
state=state,
|
|
@@ -953,6 +995,9 @@ async def _serve_registered(
|
|
|
953
995
|
attestation=attestation,
|
|
954
996
|
lifecycle=lifecycle,
|
|
955
997
|
heartbeat_stamp=state_dir / HEARTBEAT_STAMP_NAME,
|
|
998
|
+
runtimes=runtimes,
|
|
999
|
+
contracts=isolation,
|
|
1000
|
+
self_tests=self_tests,
|
|
956
1001
|
)
|
|
957
1002
|
stream.sources = UserSourcePoller(
|
|
958
1003
|
stream=SignedIntakeStream(stream.client, state),
|
|
@@ -1001,12 +1046,13 @@ async def _serve_registered(
|
|
|
1001
1046
|
settings=settings,
|
|
1002
1047
|
config=config,
|
|
1003
1048
|
state=state,
|
|
1004
|
-
|
|
1049
|
+
isolation=isolation,
|
|
1050
|
+
agent_runtimes=runtimes,
|
|
1051
|
+
directives_in_flight=in_flight,
|
|
1005
1052
|
hooks=hooks,
|
|
1006
1053
|
proxy=proxy,
|
|
1007
1054
|
credentials=credentials,
|
|
1008
1055
|
link=link,
|
|
1009
|
-
attestation=attestation,
|
|
1010
1056
|
token_source=stream,
|
|
1011
1057
|
messages=messages,
|
|
1012
1058
|
tool_servers=tool_servers,
|
|
@@ -1024,13 +1070,16 @@ async def _serve_registered(
|
|
|
1024
1070
|
_heartbeat_forever(link, stream, client, interval, stop=stop, revoked=revoked)
|
|
1025
1071
|
)
|
|
1026
1072
|
poll = asyncio.create_task(_poll_sources_forever(stream.sources, _source_poll_seconds()))
|
|
1073
|
+
self_test = asyncio.create_task(
|
|
1074
|
+
_self_test_forever(self_tests, isolation, SELF_TEST_SWEEP_SECONDS)
|
|
1075
|
+
)
|
|
1027
1076
|
readiness.ready = True
|
|
1028
1077
|
await run_runner_lifecycle_hook(hooks, HookName.RUNNER_STARTUP)
|
|
1029
1078
|
try:
|
|
1030
1079
|
await run_worker_until_terminated(worker, stop=stop)
|
|
1031
1080
|
finally:
|
|
1032
1081
|
readiness.ready = False
|
|
1033
|
-
for task in (beat, poll):
|
|
1082
|
+
for task in (beat, poll, self_test):
|
|
1034
1083
|
task.cancel()
|
|
1035
1084
|
with contextlib.suppress(asyncio.CancelledError):
|
|
1036
1085
|
await task
|
|
@@ -1112,18 +1161,36 @@ async def _poll_sources_forever(poller: UserSourcePoller, interval: float) -> No
|
|
|
1112
1161
|
await poller.poll()
|
|
1113
1162
|
|
|
1114
1163
|
|
|
1164
|
+
async def _self_test_forever(
|
|
1165
|
+
self_tests: HarnessSelfTests, isolation: ContractIsolation, interval: float
|
|
1166
|
+
) -> None:
|
|
1167
|
+
"""Each harness root's self-test at start, then whenever it falls due (local-agents 17).
|
|
1168
|
+
|
|
1169
|
+
Only roots already on disk: a Contract's uid is allocated by its first Directive,
|
|
1170
|
+
which records the allocation, and a self-test must not be the call that does it.
|
|
1171
|
+
"""
|
|
1172
|
+
|
|
1173
|
+
while True:
|
|
1174
|
+
try:
|
|
1175
|
+
await self_tests.run_due(isolation.harness_roots())
|
|
1176
|
+
except Exception as error: # noqa: BLE001 - a lost sweep is retried on the next one
|
|
1177
|
+
_logger.warning("harness self-test sweep failed: %s", error)
|
|
1178
|
+
await asyncio.sleep(interval)
|
|
1179
|
+
|
|
1180
|
+
|
|
1115
1181
|
def _activities(
|
|
1116
1182
|
fastapi: RunnerFastApiClient,
|
|
1117
1183
|
*,
|
|
1118
1184
|
settings: WorkerSettings,
|
|
1119
1185
|
config: RunnerConfig,
|
|
1120
1186
|
state: RunnerState,
|
|
1121
|
-
|
|
1187
|
+
isolation: ContractIsolation,
|
|
1188
|
+
agent_runtimes: Mapping[str, AgentRuntime],
|
|
1189
|
+
directives_in_flight: DirectivesInFlight,
|
|
1122
1190
|
hooks: HookRunner,
|
|
1123
1191
|
proxy: LlmProxy,
|
|
1124
1192
|
credentials: CredentialResolver,
|
|
1125
1193
|
link: HeartbeatLink,
|
|
1126
|
-
attestation: HostAttestation | None,
|
|
1127
1194
|
token_source: DirectiveTokenSource | None = None,
|
|
1128
1195
|
messages: MessageStore | None = None,
|
|
1129
1196
|
signaller: TemporalWorkflowSignaller | None = None,
|
|
@@ -1136,7 +1203,6 @@ def _activities(
|
|
|
1136
1203
|
api_base_url=os.getenv("GITHUB_API_BASE_URL", "https://api.github.com"),
|
|
1137
1204
|
)
|
|
1138
1205
|
static_git_token = os.getenv("AGENTIC_OS_GIT_TOKEN") or None
|
|
1139
|
-
isolation = build_contract_isolation(settings, can_separate_uids=can_change_uid)
|
|
1140
1206
|
attempt_records = AttemptRecords(config.state_dir, workspace_root=settings.WORKSPACE_ROOT)
|
|
1141
1207
|
# Only the dead go: a live record is a harness this process lost on restart, still
|
|
1142
1208
|
# writing its Workspace, and the next attempt must keep being refused (RR-05).
|
|
@@ -1144,9 +1210,7 @@ def _activities(
|
|
|
1144
1210
|
ralph = RunnerRalphActivities(
|
|
1145
1211
|
fastapi,
|
|
1146
1212
|
runtime_context_resolver=WorkerRuntimeContextResolver(fastapi),
|
|
1147
|
-
agent_runtimes=
|
|
1148
|
-
settings, attestation.clis if attestation is not None else []
|
|
1149
|
-
),
|
|
1213
|
+
agent_runtimes=agent_runtimes,
|
|
1150
1214
|
git_workspace=LocalGitWorkspace(
|
|
1151
1215
|
git_token=static_git_token,
|
|
1152
1216
|
git_token_provider=(
|
|
@@ -1177,6 +1241,7 @@ def _activities(
|
|
|
1177
1241
|
workflow_signaller=signaller,
|
|
1178
1242
|
tool_server_health=tool_servers,
|
|
1179
1243
|
attempt_records=attempt_records,
|
|
1244
|
+
directives_in_flight=directives_in_flight,
|
|
1180
1245
|
)
|
|
1181
1246
|
return [
|
|
1182
1247
|
*ralph.activity_callables(),
|
|
@@ -101,6 +101,24 @@ class DirectiveResult:
|
|
|
101
101
|
evidence: DirectiveEvidence
|
|
102
102
|
|
|
103
103
|
|
|
104
|
+
# The permission mode of a runtime whose guard configuration is incomplete: it refuses
|
|
105
|
+
# every Directive, so there is no mode it passes.
|
|
106
|
+
REFUSED_PERMISSION_MODE = "refused"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class RuntimeCapabilities:
|
|
111
|
+
"""What a runtime declares about itself on the heartbeat (local-agents 17).
|
|
112
|
+
|
|
113
|
+
``permission_mode`` is the fixed permission and approval mode the runtime passes its
|
|
114
|
+
harness, as a short patterned string. It is reported, never the gate: the Runner's
|
|
115
|
+
own floor is (ADR-0011 §12).
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
auth_modes: frozenset[AuthMode]
|
|
119
|
+
permission_mode: str
|
|
120
|
+
|
|
121
|
+
|
|
104
122
|
@runtime_checkable
|
|
105
123
|
class AgentRuntime(Protocol):
|
|
106
124
|
"""The pluggable engine an Agent uses to execute one Directive per step."""
|
|
@@ -111,6 +129,8 @@ class AgentRuntime(Protocol):
|
|
|
111
129
|
# counts as "an API key is present" when the Runner chooses the mode.
|
|
112
130
|
host_api_key: bool
|
|
113
131
|
|
|
132
|
+
def capabilities(self) -> RuntimeCapabilities: ...
|
|
133
|
+
|
|
114
134
|
async def execute_directive(self, request: DirectiveRequest) -> DirectiveResult: ...
|
|
115
135
|
|
|
116
136
|
|