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 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
+ )