ldw-agent 0.2.1__py3-none-any.whl
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.
- ldw_agent/__init__.py +101 -0
- ldw_agent/broker.py +489 -0
- ldw_agent/consent.py +267 -0
- ldw_agent/errors.py +25 -0
- ldw_agent/extension.py +8 -0
- ldw_agent/init_assist.py +172 -0
- ldw_agent/inventory.py +481 -0
- ldw_agent/models.py +326 -0
- ldw_agent/process.py +310 -0
- ldw_agent/providers.py +638 -0
- ldw_agent/schema.py +247 -0
- ldw_agent-0.2.1.dist-info/METADATA +24 -0
- ldw_agent-0.2.1.dist-info/RECORD +17 -0
- ldw_agent-0.2.1.dist-info/WHEEL +4 -0
- ldw_agent-0.2.1.dist-info/entry_points.txt +2 -0
- ldw_agent-0.2.1.dist-info/licenses/LICENSE +201 -0
- ldw_agent-0.2.1.dist-info/licenses/NOTICE +4 -0
ldw_agent/__init__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Capability-bounded local coding-agent runtimes."""
|
|
2
|
+
|
|
3
|
+
from ldw_agent.broker import AgentBroker
|
|
4
|
+
from ldw_agent.consent import ProjectConsentStore
|
|
5
|
+
from ldw_agent.errors import (
|
|
6
|
+
AgentContextError,
|
|
7
|
+
AgentLimitError,
|
|
8
|
+
AgentPolicyError,
|
|
9
|
+
AgentProtocolError,
|
|
10
|
+
AgentRuntimeError,
|
|
11
|
+
AgentSchemaError,
|
|
12
|
+
)
|
|
13
|
+
from ldw_agent.inventory import (
|
|
14
|
+
MirrorSession,
|
|
15
|
+
TrackedInventoryBuilder,
|
|
16
|
+
ephemeral_tracked_mirror,
|
|
17
|
+
exclusion_reason,
|
|
18
|
+
)
|
|
19
|
+
from ldw_agent.models import (
|
|
20
|
+
AgentCapability,
|
|
21
|
+
AgentChangeKind,
|
|
22
|
+
AgentEvent,
|
|
23
|
+
AgentEventKind,
|
|
24
|
+
AgentFileChange,
|
|
25
|
+
AgentLimits,
|
|
26
|
+
AgentRequest,
|
|
27
|
+
AgentResult,
|
|
28
|
+
AgentRuntimeName,
|
|
29
|
+
AgentStatus,
|
|
30
|
+
CapabilityPolicy,
|
|
31
|
+
ConsentRecord,
|
|
32
|
+
ConsentScope,
|
|
33
|
+
ProjectRuntimePreference,
|
|
34
|
+
RuntimeProbe,
|
|
35
|
+
TerminalInvocation,
|
|
36
|
+
TrackedFile,
|
|
37
|
+
TrackedInventory,
|
|
38
|
+
request_context_hash,
|
|
39
|
+
)
|
|
40
|
+
from ldw_agent.process import (
|
|
41
|
+
BoundedProcessExecutor,
|
|
42
|
+
ParsedProviderLine,
|
|
43
|
+
ProcessTranscript,
|
|
44
|
+
validate_exact_terminal,
|
|
45
|
+
)
|
|
46
|
+
from ldw_agent.providers import (
|
|
47
|
+
AgentRuntime,
|
|
48
|
+
ClaudeAgentRuntime,
|
|
49
|
+
CodexAgentRuntime,
|
|
50
|
+
UnavailableAgentRuntime,
|
|
51
|
+
)
|
|
52
|
+
from ldw_agent.schema import (
|
|
53
|
+
canonical_schema_json,
|
|
54
|
+
parse_and_validate_structured_output,
|
|
55
|
+
validate_schema_definition,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"AgentBroker",
|
|
60
|
+
"AgentCapability",
|
|
61
|
+
"AgentChangeKind",
|
|
62
|
+
"AgentContextError",
|
|
63
|
+
"AgentEvent",
|
|
64
|
+
"AgentEventKind",
|
|
65
|
+
"AgentFileChange",
|
|
66
|
+
"AgentLimitError",
|
|
67
|
+
"AgentLimits",
|
|
68
|
+
"AgentPolicyError",
|
|
69
|
+
"AgentProtocolError",
|
|
70
|
+
"AgentRequest",
|
|
71
|
+
"AgentResult",
|
|
72
|
+
"AgentRuntime",
|
|
73
|
+
"AgentRuntimeError",
|
|
74
|
+
"AgentRuntimeName",
|
|
75
|
+
"AgentSchemaError",
|
|
76
|
+
"AgentStatus",
|
|
77
|
+
"BoundedProcessExecutor",
|
|
78
|
+
"CapabilityPolicy",
|
|
79
|
+
"ClaudeAgentRuntime",
|
|
80
|
+
"CodexAgentRuntime",
|
|
81
|
+
"ConsentRecord",
|
|
82
|
+
"ConsentScope",
|
|
83
|
+
"MirrorSession",
|
|
84
|
+
"ParsedProviderLine",
|
|
85
|
+
"ProcessTranscript",
|
|
86
|
+
"ProjectConsentStore",
|
|
87
|
+
"ProjectRuntimePreference",
|
|
88
|
+
"RuntimeProbe",
|
|
89
|
+
"TerminalInvocation",
|
|
90
|
+
"TrackedFile",
|
|
91
|
+
"TrackedInventory",
|
|
92
|
+
"TrackedInventoryBuilder",
|
|
93
|
+
"UnavailableAgentRuntime",
|
|
94
|
+
"canonical_schema_json",
|
|
95
|
+
"ephemeral_tracked_mirror",
|
|
96
|
+
"exclusion_reason",
|
|
97
|
+
"parse_and_validate_structured_output",
|
|
98
|
+
"request_context_hash",
|
|
99
|
+
"validate_exact_terminal",
|
|
100
|
+
"validate_schema_definition",
|
|
101
|
+
]
|
ldw_agent/broker.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ldw_agent.consent import ProjectConsentStore
|
|
9
|
+
from ldw_agent.errors import (
|
|
10
|
+
AgentLimitError,
|
|
11
|
+
AgentPolicyError,
|
|
12
|
+
AgentRuntimeError,
|
|
13
|
+
AgentSchemaError,
|
|
14
|
+
)
|
|
15
|
+
from ldw_agent.inventory import (
|
|
16
|
+
TrackedInventoryBuilder,
|
|
17
|
+
ephemeral_tracked_mirror,
|
|
18
|
+
)
|
|
19
|
+
from ldw_agent.models import (
|
|
20
|
+
AgentCapability,
|
|
21
|
+
AgentEvent,
|
|
22
|
+
AgentEventKind,
|
|
23
|
+
AgentRequest,
|
|
24
|
+
AgentResult,
|
|
25
|
+
AgentRuntimeName,
|
|
26
|
+
AgentStatus,
|
|
27
|
+
RuntimeProbe,
|
|
28
|
+
request_context_hash,
|
|
29
|
+
)
|
|
30
|
+
from ldw_agent.process import BoundedProcessExecutor
|
|
31
|
+
from ldw_agent.providers import (
|
|
32
|
+
AgentRuntime,
|
|
33
|
+
ClaudeAgentRuntime,
|
|
34
|
+
CodexAgentRuntime,
|
|
35
|
+
)
|
|
36
|
+
from ldw_agent.schema import (
|
|
37
|
+
canonical_schema_json,
|
|
38
|
+
parse_and_validate_structured_output,
|
|
39
|
+
)
|
|
40
|
+
from local_devops_workbench.redaction import Redactor
|
|
41
|
+
|
|
42
|
+
NO_RUNTIME_REASON = "no eligible coding-agent runtime is installed"
|
|
43
|
+
NO_CONSENT_REASON = "coding-agent execution requires explicit project consent"
|
|
44
|
+
MISSING_CAPABILITY_REASON = "coding-agent execution exceeds the granted capability policy"
|
|
45
|
+
MAX_TRANSIENT_RETRIES = 2
|
|
46
|
+
TRANSIENT_FAILURE_MARKERS = (
|
|
47
|
+
"429",
|
|
48
|
+
"502",
|
|
49
|
+
"503",
|
|
50
|
+
"connection reset",
|
|
51
|
+
"rate limit",
|
|
52
|
+
"temporarily unavailable",
|
|
53
|
+
"temporary failure",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AgentBroker:
|
|
58
|
+
"""Select and execute one consented runtime against an isolated tracked-file mirror."""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
consent_store: ProjectConsentStore,
|
|
64
|
+
runtimes: Sequence[AgentRuntime] | None = None,
|
|
65
|
+
inventory_builder: TrackedInventoryBuilder | None = None,
|
|
66
|
+
process_executor: BoundedProcessExecutor | None = None,
|
|
67
|
+
source_environment: Mapping[str, str] | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
configured = tuple(
|
|
70
|
+
(CodexAgentRuntime(), ClaudeAgentRuntime()) if runtimes is None else runtimes
|
|
71
|
+
)
|
|
72
|
+
if any(not isinstance(runtime.name, AgentRuntimeName) for runtime in configured):
|
|
73
|
+
raise ValueError("agent runtimes must use a typed runtime name")
|
|
74
|
+
by_name = {runtime.name: runtime for runtime in configured}
|
|
75
|
+
if len(by_name) != len(configured):
|
|
76
|
+
raise ValueError("agent runtime names must be unique")
|
|
77
|
+
if AgentRuntimeName.UNAVAILABLE in by_name:
|
|
78
|
+
raise ValueError("the unavailable runtime is broker-owned fallback state")
|
|
79
|
+
self._runtimes = by_name
|
|
80
|
+
self._consent_store = consent_store
|
|
81
|
+
self._inventory_builder = inventory_builder
|
|
82
|
+
self._process_executor = process_executor or BoundedProcessExecutor()
|
|
83
|
+
environment = os.environ if source_environment is None else source_environment
|
|
84
|
+
self._source_environment = dict(environment)
|
|
85
|
+
self._redactor = Redactor.from_environment(self._source_environment)
|
|
86
|
+
|
|
87
|
+
def probe(self) -> tuple[RuntimeProbe, ...]:
|
|
88
|
+
"""Probe configured providers in stable runtime-name order."""
|
|
89
|
+
return tuple(
|
|
90
|
+
self._safe_probe(self._runtimes[name])
|
|
91
|
+
for name in sorted(self._runtimes, key=lambda item: item.value)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def eligible_runtimes(self, request: AgentRequest) -> tuple[RuntimeProbe, ...]:
|
|
95
|
+
"""Return available providers allowed by the policy, without reading project files."""
|
|
96
|
+
probes: list[RuntimeProbe] = []
|
|
97
|
+
for name in self._ordered_runtime_names(request):
|
|
98
|
+
runtime = self._runtimes.get(name)
|
|
99
|
+
if runtime is None or not runtime.required_capabilities.issubset(
|
|
100
|
+
request.policy.capabilities
|
|
101
|
+
):
|
|
102
|
+
continue
|
|
103
|
+
probe = self._safe_probe(runtime)
|
|
104
|
+
if probe.available:
|
|
105
|
+
probes.append(probe)
|
|
106
|
+
return tuple(probes)
|
|
107
|
+
|
|
108
|
+
async def run(self, request: AgentRequest) -> AgentResult:
|
|
109
|
+
runtime: AgentRuntime | None = None
|
|
110
|
+
probe: RuntimeProbe | None = None
|
|
111
|
+
context_hash: str | None = None
|
|
112
|
+
try:
|
|
113
|
+
if request.output_schema is not None:
|
|
114
|
+
canonical_schema_json(request.output_schema)
|
|
115
|
+
selection = self._select_runtime(request)
|
|
116
|
+
if isinstance(selection, AgentResult):
|
|
117
|
+
return selection
|
|
118
|
+
runtime, probe = selection
|
|
119
|
+
|
|
120
|
+
# The repository is deliberately not read until both capability and consent
|
|
121
|
+
# checks have selected one provider.
|
|
122
|
+
inventory_builder = self._inventory_builder or TrackedInventoryBuilder()
|
|
123
|
+
inventory = inventory_builder.build(request.project_root, request.policy)
|
|
124
|
+
context_hash = request_context_hash(request, inventory)
|
|
125
|
+
with ephemeral_tracked_mirror(inventory, request.policy) as mirror:
|
|
126
|
+
schema_path = self._write_schema(request, mirror.control_root)
|
|
127
|
+
# Consume one-shot consent at the last synchronous boundary before
|
|
128
|
+
# process creation. A revoked or concurrently consumed record fails closed.
|
|
129
|
+
if not self._consent_store.authorized(
|
|
130
|
+
project_id=request.project_id,
|
|
131
|
+
project_root=request.project_root,
|
|
132
|
+
runtime=runtime.name,
|
|
133
|
+
policy=request.policy,
|
|
134
|
+
consume=True,
|
|
135
|
+
):
|
|
136
|
+
return self._result(
|
|
137
|
+
request,
|
|
138
|
+
runtime=runtime.name,
|
|
139
|
+
status=AgentStatus.DENIED,
|
|
140
|
+
error=NO_CONSENT_REASON,
|
|
141
|
+
probe=probe,
|
|
142
|
+
context_hash=context_hash,
|
|
143
|
+
)
|
|
144
|
+
execution_request = request
|
|
145
|
+
collected_events: list[AgentEvent] = []
|
|
146
|
+
observed_output_bytes = 0
|
|
147
|
+
transient_retries = 0
|
|
148
|
+
correction_attempted = False
|
|
149
|
+
while True:
|
|
150
|
+
invocation = runtime.build_invocation(
|
|
151
|
+
execution_request,
|
|
152
|
+
mirror,
|
|
153
|
+
probe,
|
|
154
|
+
self._source_environment,
|
|
155
|
+
schema_path=schema_path,
|
|
156
|
+
)
|
|
157
|
+
expected = runtime.build_invocation(
|
|
158
|
+
execution_request,
|
|
159
|
+
mirror,
|
|
160
|
+
probe,
|
|
161
|
+
self._source_environment,
|
|
162
|
+
schema_path=schema_path,
|
|
163
|
+
)
|
|
164
|
+
self._validate_probe_binding(runtime, probe, invocation.argv[0])
|
|
165
|
+
self._validate_probe_binding(runtime, probe, expected.argv[0])
|
|
166
|
+
transcript = await self._process_executor.run(
|
|
167
|
+
invocation,
|
|
168
|
+
expected=expected,
|
|
169
|
+
parser=runtime,
|
|
170
|
+
limits=request.policy.limits,
|
|
171
|
+
)
|
|
172
|
+
observed_output_bytes += len(transcript.stdout.encode()) + len(
|
|
173
|
+
transcript.stderr.encode()
|
|
174
|
+
)
|
|
175
|
+
if observed_output_bytes > request.policy.limits.max_output_bytes:
|
|
176
|
+
raise AgentLimitError("provider retry output exceeds max_output_bytes")
|
|
177
|
+
_append_events(collected_events, transcript.events)
|
|
178
|
+
if len(collected_events) > request.policy.limits.max_events:
|
|
179
|
+
raise AgentLimitError("provider retry output exceeds max_events")
|
|
180
|
+
if transcript.returncode != 0:
|
|
181
|
+
detail = transcript.stderr.strip() or (
|
|
182
|
+
f"{runtime.name.value} exited {transcript.returncode}"
|
|
183
|
+
)
|
|
184
|
+
if transient_retries < MAX_TRANSIENT_RETRIES and _is_transient_failure(
|
|
185
|
+
detail
|
|
186
|
+
):
|
|
187
|
+
transient_retries += 1
|
|
188
|
+
collected_events.append(
|
|
189
|
+
_broker_event(
|
|
190
|
+
len(collected_events),
|
|
191
|
+
runtime.name,
|
|
192
|
+
f"transient provider failure; retry "
|
|
193
|
+
f"{transient_retries}/{MAX_TRANSIENT_RETRIES}",
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
await asyncio.sleep(0)
|
|
197
|
+
continue
|
|
198
|
+
return self._result(
|
|
199
|
+
request,
|
|
200
|
+
runtime=runtime.name,
|
|
201
|
+
status=AgentStatus.FAILED,
|
|
202
|
+
error=self._safe_error(detail),
|
|
203
|
+
probe=probe,
|
|
204
|
+
context_hash=context_hash,
|
|
205
|
+
events=tuple(collected_events),
|
|
206
|
+
)
|
|
207
|
+
if transcript.final_text is None:
|
|
208
|
+
return self._result(
|
|
209
|
+
request,
|
|
210
|
+
runtime=runtime.name,
|
|
211
|
+
status=AgentStatus.INVALID_OUTPUT,
|
|
212
|
+
error="provider completed without a final response",
|
|
213
|
+
probe=probe,
|
|
214
|
+
context_hash=context_hash,
|
|
215
|
+
events=tuple(collected_events),
|
|
216
|
+
)
|
|
217
|
+
structured_output: object | None = None
|
|
218
|
+
if request.output_schema is not None:
|
|
219
|
+
try:
|
|
220
|
+
structured_output = parse_and_validate_structured_output(
|
|
221
|
+
transcript.final_text,
|
|
222
|
+
request.output_schema,
|
|
223
|
+
max_bytes=request.policy.limits.max_output_bytes,
|
|
224
|
+
)
|
|
225
|
+
except AgentSchemaError:
|
|
226
|
+
correction = _schema_correction_request(
|
|
227
|
+
request,
|
|
228
|
+
correction_attempted=correction_attempted,
|
|
229
|
+
)
|
|
230
|
+
if correction is None:
|
|
231
|
+
raise
|
|
232
|
+
correction_attempted = True
|
|
233
|
+
execution_request = correction
|
|
234
|
+
collected_events.append(
|
|
235
|
+
_broker_event(
|
|
236
|
+
len(collected_events),
|
|
237
|
+
runtime.name,
|
|
238
|
+
"structured output rejected; requesting one correction",
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
continue
|
|
242
|
+
break
|
|
243
|
+
changes = mirror.collect_changes()
|
|
244
|
+
return AgentResult(
|
|
245
|
+
request_id=request.request_id,
|
|
246
|
+
project_id=request.project_id,
|
|
247
|
+
runtime=runtime.name,
|
|
248
|
+
status=AgentStatus.SUCCEEDED,
|
|
249
|
+
context_hash=context_hash,
|
|
250
|
+
events=tuple(collected_events),
|
|
251
|
+
final_text=transcript.final_text,
|
|
252
|
+
structured_output=structured_output,
|
|
253
|
+
changes=changes,
|
|
254
|
+
probe=probe,
|
|
255
|
+
)
|
|
256
|
+
except asyncio.CancelledError:
|
|
257
|
+
return self._result(
|
|
258
|
+
request,
|
|
259
|
+
runtime=runtime.name if runtime is not None else AgentRuntimeName.UNAVAILABLE,
|
|
260
|
+
status=AgentStatus.CANCELLED,
|
|
261
|
+
error="coding-agent execution was cancelled",
|
|
262
|
+
probe=probe,
|
|
263
|
+
context_hash=context_hash,
|
|
264
|
+
)
|
|
265
|
+
except AgentLimitError as error:
|
|
266
|
+
return self._result(
|
|
267
|
+
request,
|
|
268
|
+
runtime=runtime.name if runtime is not None else AgentRuntimeName.UNAVAILABLE,
|
|
269
|
+
status=AgentStatus.LIMIT_EXCEEDED,
|
|
270
|
+
error=self._safe_error(error),
|
|
271
|
+
probe=probe,
|
|
272
|
+
context_hash=context_hash,
|
|
273
|
+
)
|
|
274
|
+
except AgentSchemaError as error:
|
|
275
|
+
return self._result(
|
|
276
|
+
request,
|
|
277
|
+
runtime=runtime.name if runtime is not None else AgentRuntimeName.UNAVAILABLE,
|
|
278
|
+
status=AgentStatus.INVALID_OUTPUT,
|
|
279
|
+
error=self._safe_error(error),
|
|
280
|
+
probe=probe,
|
|
281
|
+
context_hash=context_hash,
|
|
282
|
+
)
|
|
283
|
+
except AgentPolicyError as error:
|
|
284
|
+
return self._result(
|
|
285
|
+
request,
|
|
286
|
+
runtime=runtime.name if runtime is not None else AgentRuntimeName.UNAVAILABLE,
|
|
287
|
+
status=AgentStatus.DENIED,
|
|
288
|
+
error=self._safe_error(error),
|
|
289
|
+
probe=probe,
|
|
290
|
+
context_hash=context_hash,
|
|
291
|
+
)
|
|
292
|
+
except AgentRuntimeError as error:
|
|
293
|
+
return self._result(
|
|
294
|
+
request,
|
|
295
|
+
runtime=runtime.name if runtime is not None else AgentRuntimeName.UNAVAILABLE,
|
|
296
|
+
status=AgentStatus.FAILED,
|
|
297
|
+
error=self._safe_error(error),
|
|
298
|
+
probe=probe,
|
|
299
|
+
context_hash=context_hash,
|
|
300
|
+
)
|
|
301
|
+
except OSError as error:
|
|
302
|
+
return self._result(
|
|
303
|
+
request,
|
|
304
|
+
runtime=runtime.name if runtime is not None else AgentRuntimeName.UNAVAILABLE,
|
|
305
|
+
status=AgentStatus.FAILED,
|
|
306
|
+
error=self._safe_error(error),
|
|
307
|
+
probe=probe,
|
|
308
|
+
context_hash=context_hash,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def _select_runtime(
|
|
312
|
+
self,
|
|
313
|
+
request: AgentRequest,
|
|
314
|
+
) -> tuple[AgentRuntime, RuntimeProbe] | AgentResult:
|
|
315
|
+
found_available = False
|
|
316
|
+
found_missing_capability = False
|
|
317
|
+
found_consent_missing = False
|
|
318
|
+
first_available: tuple[AgentRuntime, RuntimeProbe] | None = None
|
|
319
|
+
for name in self._ordered_runtime_names(request):
|
|
320
|
+
runtime = self._runtimes.get(name)
|
|
321
|
+
if runtime is None:
|
|
322
|
+
continue
|
|
323
|
+
probe = self._safe_probe(runtime)
|
|
324
|
+
if not probe.available:
|
|
325
|
+
continue
|
|
326
|
+
found_available = True
|
|
327
|
+
if first_available is None:
|
|
328
|
+
first_available = (runtime, probe)
|
|
329
|
+
if not runtime.required_capabilities.issubset(request.policy.capabilities):
|
|
330
|
+
found_missing_capability = True
|
|
331
|
+
continue
|
|
332
|
+
if self._consent_store.authorized(
|
|
333
|
+
project_id=request.project_id,
|
|
334
|
+
project_root=request.project_root,
|
|
335
|
+
runtime=name,
|
|
336
|
+
policy=request.policy,
|
|
337
|
+
):
|
|
338
|
+
return runtime, probe
|
|
339
|
+
found_consent_missing = True
|
|
340
|
+
if found_available:
|
|
341
|
+
runtime_name = (
|
|
342
|
+
first_available[0].name
|
|
343
|
+
if first_available is not None
|
|
344
|
+
else AgentRuntimeName.UNAVAILABLE
|
|
345
|
+
)
|
|
346
|
+
reason = (
|
|
347
|
+
NO_CONSENT_REASON
|
|
348
|
+
if found_consent_missing
|
|
349
|
+
else MISSING_CAPABILITY_REASON
|
|
350
|
+
if found_missing_capability
|
|
351
|
+
else NO_CONSENT_REASON
|
|
352
|
+
)
|
|
353
|
+
return self._result(
|
|
354
|
+
request,
|
|
355
|
+
runtime=runtime_name,
|
|
356
|
+
status=AgentStatus.DENIED,
|
|
357
|
+
error=reason,
|
|
358
|
+
probe=first_available[1] if first_available is not None else None,
|
|
359
|
+
)
|
|
360
|
+
return self._result(
|
|
361
|
+
request,
|
|
362
|
+
runtime=AgentRuntimeName.UNAVAILABLE,
|
|
363
|
+
status=AgentStatus.UNAVAILABLE,
|
|
364
|
+
error=NO_RUNTIME_REASON,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def _ordered_runtime_names(self, request: AgentRequest) -> tuple[AgentRuntimeName, ...]:
|
|
368
|
+
if request.runtime is not None:
|
|
369
|
+
return (request.runtime,)
|
|
370
|
+
preferred = self._consent_store.preference_for(request.project_id)
|
|
371
|
+
ordered = (*preferred, *request.policy.runtime_preference)
|
|
372
|
+
return tuple(dict.fromkeys(ordered))
|
|
373
|
+
|
|
374
|
+
def _safe_probe(self, runtime: AgentRuntime) -> RuntimeProbe:
|
|
375
|
+
try:
|
|
376
|
+
probe = runtime.probe(self._source_environment)
|
|
377
|
+
if probe.runtime is not runtime.name:
|
|
378
|
+
raise ValueError("runtime probe returned the wrong identity")
|
|
379
|
+
except Exception: # A third-party adapter probe cannot break deterministic fallback.
|
|
380
|
+
return RuntimeProbe(
|
|
381
|
+
runtime=runtime.name,
|
|
382
|
+
available=False,
|
|
383
|
+
reason=f"{runtime.name.value} runtime probe failed",
|
|
384
|
+
)
|
|
385
|
+
return probe
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _validate_probe_binding(
|
|
389
|
+
runtime: AgentRuntime,
|
|
390
|
+
probe: RuntimeProbe,
|
|
391
|
+
invoked_executable: str,
|
|
392
|
+
) -> None:
|
|
393
|
+
if probe.executable is None:
|
|
394
|
+
raise AgentPolicyError("runtime invocation has no probed executable")
|
|
395
|
+
try:
|
|
396
|
+
executable = probe.executable.resolve(strict=True)
|
|
397
|
+
except OSError as error:
|
|
398
|
+
raise AgentPolicyError("runtime executable disappeared after probing") from error
|
|
399
|
+
if runtime.name is not probe.runtime or str(executable) != invoked_executable:
|
|
400
|
+
raise AgentPolicyError("runtime invocation does not match its executable probe")
|
|
401
|
+
|
|
402
|
+
@staticmethod
|
|
403
|
+
def _write_schema(request: AgentRequest, control_root: Path) -> Path | None:
|
|
404
|
+
if request.output_schema is None:
|
|
405
|
+
return None
|
|
406
|
+
schema_path = control_root / "output-schema.json"
|
|
407
|
+
descriptor = os.open(
|
|
408
|
+
schema_path,
|
|
409
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
410
|
+
0o600,
|
|
411
|
+
)
|
|
412
|
+
try:
|
|
413
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
414
|
+
handle.write(canonical_schema_json(request.output_schema))
|
|
415
|
+
handle.write("\n")
|
|
416
|
+
handle.flush()
|
|
417
|
+
os.fsync(handle.fileno())
|
|
418
|
+
except Exception:
|
|
419
|
+
schema_path.unlink(missing_ok=True)
|
|
420
|
+
raise
|
|
421
|
+
return schema_path
|
|
422
|
+
|
|
423
|
+
def _safe_error(self, error: object) -> str:
|
|
424
|
+
return self._redactor.redact(str(error))[:1_000]
|
|
425
|
+
|
|
426
|
+
@staticmethod
|
|
427
|
+
def _result(
|
|
428
|
+
request: AgentRequest,
|
|
429
|
+
*,
|
|
430
|
+
runtime: AgentRuntimeName,
|
|
431
|
+
status: AgentStatus,
|
|
432
|
+
error: str,
|
|
433
|
+
probe: RuntimeProbe | None = None,
|
|
434
|
+
context_hash: str | None = None,
|
|
435
|
+
events: tuple[AgentEvent, ...] = (),
|
|
436
|
+
) -> AgentResult:
|
|
437
|
+
return AgentResult(
|
|
438
|
+
request_id=request.request_id,
|
|
439
|
+
project_id=request.project_id,
|
|
440
|
+
runtime=runtime,
|
|
441
|
+
status=status,
|
|
442
|
+
context_hash=context_hash,
|
|
443
|
+
events=events,
|
|
444
|
+
error=error,
|
|
445
|
+
probe=probe,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _is_transient_failure(detail: str) -> bool:
|
|
450
|
+
normalized = detail.casefold()
|
|
451
|
+
return any(marker in normalized for marker in TRANSIENT_FAILURE_MARKERS)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _schema_correction_request(
|
|
455
|
+
request: AgentRequest,
|
|
456
|
+
*,
|
|
457
|
+
correction_attempted: bool,
|
|
458
|
+
) -> AgentRequest | None:
|
|
459
|
+
if correction_attempted or request.policy.capabilities.intersection(
|
|
460
|
+
{
|
|
461
|
+
AgentCapability.WRITE_EPHEMERAL_MIRROR,
|
|
462
|
+
AgentCapability.CREATE_EPHEMERAL_FILES,
|
|
463
|
+
}
|
|
464
|
+
):
|
|
465
|
+
return None
|
|
466
|
+
suffix = (
|
|
467
|
+
"\n\nYour previous response did not match the required output schema. "
|
|
468
|
+
"Return one corrected JSON value only; do not add prose or code fences."
|
|
469
|
+
)
|
|
470
|
+
prompt = request.prompt + suffix
|
|
471
|
+
if len(prompt.encode()) > request.policy.limits.max_prompt_bytes:
|
|
472
|
+
return None
|
|
473
|
+
return request.model_copy(update={"prompt": prompt})
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _append_events(destination: list[AgentEvent], events: tuple[AgentEvent, ...]) -> None:
|
|
477
|
+
for event in events:
|
|
478
|
+
destination.append(event.model_copy(update={"sequence": len(destination)}))
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _broker_event(sequence: int, runtime: AgentRuntimeName, message: str) -> AgentEvent:
|
|
482
|
+
return AgentEvent(
|
|
483
|
+
sequence=sequence,
|
|
484
|
+
kind=AgentEventKind.DIAGNOSTIC,
|
|
485
|
+
runtime=runtime,
|
|
486
|
+
provider_type="broker.retry",
|
|
487
|
+
message=message,
|
|
488
|
+
stream="broker",
|
|
489
|
+
)
|