agent-orchestration-process 0.2.0__tar.gz → 0.2.2__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.
Files changed (28) hide show
  1. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/PKG-INFO +1 -1
  2. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/pyproject.toml +1 -1
  3. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/pyproject.toml.orig +1 -1
  4. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/cli.py +101 -10
  5. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/isolation.py +1 -1
  6. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/models.py +29 -0
  7. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/prompt_transport.py +25 -0
  8. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/provider_versions.py +6 -0
  9. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/runner.py +239 -36
  10. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/LICENSE +0 -0
  11. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/README.md +0 -0
  12. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/__init__.py +0 -0
  13. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/__main__.py +0 -0
  14. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/batch.py +0 -0
  15. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/codex_auth.py +0 -0
  16. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/codex_routes.py +0 -0
  17. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/dsh_runner.mjs +0 -0
  18. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/egress.py +0 -0
  19. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/egress_bridge.py +0 -0
  20. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/integration.py +0 -0
  21. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/locks.py +0 -0
  22. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/migration.py +0 -0
  23. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/model_catalog.py +0 -0
  24. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/model_listing.py +0 -0
  25. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/pricing.py +0 -0
  26. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/worktrees.py +0 -0
  27. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/zcode.py +0 -0
  28. {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/zcode_protocol.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-orchestration-process
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Run bounded, non-interactive jobs across agent harnesses with isolation, exact resume, and provenance
5
5
  Keywords: agent-orchestration,ai-agents,autoresearch,coding-agents,git-worktrees
6
6
  Author: AOP contributors
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "agent-orchestration-process"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Run bounded, non-interactive jobs across agent harnesses with isolation, exact resume, and provenance"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "agent-orchestration-process"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Run bounded, non-interactive jobs across agent harnesses with isolation, exact resume, and provenance"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -22,8 +22,14 @@ from .isolation import PROFILES, explain_profile, resolve_policy
22
22
  from .locks import exclusive_lock, task_lock_path
23
23
  from .model_catalog import ModelCatalog, ensure_catalog_fresh
24
24
  from .model_listing import AGENTS, AvailableModel, list_models
25
- from .models import RunRequest, RunResult
26
- from .runner import AgentRunner, RunStore, adapter_for, provider_runtime_record
25
+ from .models import PromptPart, RunRequest, RunResult
26
+ from .runner import (
27
+ AgentRunner,
28
+ RunStore,
29
+ adapter_for,
30
+ plan_inputs,
31
+ provider_runtime_record,
32
+ )
27
33
  from .migration import migrate_runs, migration_roots, print_counts
28
34
  from .worktrees import (
29
35
  AOPError,
@@ -396,6 +402,25 @@ def main(argv: Sequence[str] | None = None) -> int:
396
402
  input_names=tuple(Path(path).name for path in args.input_paths),
397
403
  ).to_dict()
398
404
  preview["provider"] = args.agent
405
+ planned = plan_inputs(
406
+ args.input_paths,
407
+ excludes=args.input_excludes,
408
+ skip_external_links=args.skip_external_links,
409
+ protected=(manager.state_dir, manager.sealed_runtime_dir),
410
+ )
411
+ preview["inputs"]["planned"] = [
412
+ {
413
+ "name": plan.alias,
414
+ "source": os.fspath(plan.source),
415
+ "files": len(plan.entries) if plan.entries else 1,
416
+ "size_bytes": sum(path.stat().st_size for _, path in plan.entries)
417
+ if plan.source.is_dir()
418
+ else plan.source.stat().st_size,
419
+ "excluded": list(plan.excluded),
420
+ "links": list(plan.links),
421
+ }
422
+ for plan in planned
423
+ ]
399
424
  preview["provider_runtime"] = provider_runtime_record(
400
425
  adapter_for(args.agent)
401
426
  )
@@ -409,9 +434,11 @@ def main(argv: Sequence[str] | None = None) -> int:
409
434
  prefix="aop: ",
410
435
  stream=sys.stderr,
411
436
  )
437
+ prompt, prompt_parts = _read_prompt(args)
412
438
  result = AgentRunner(manager, adapter_for(args.agent)).run(
413
439
  task=task,
414
- prompt=_read_prompt(args),
440
+ prompt=prompt,
441
+ prompt_parts=prompt_parts,
415
442
  base=args.base,
416
443
  model=args.model,
417
444
  inference_provider=args.inference_provider,
@@ -422,6 +449,8 @@ def main(argv: Sequence[str] | None = None) -> int:
422
449
  timeout_seconds=args.timeout,
423
450
  artifacts=args.artifact,
424
451
  input_paths=args.input_paths,
452
+ input_excludes=args.input_excludes,
453
+ skip_external_links=args.skip_external_links,
425
454
  system_prompt=_read_system_prompt(args),
426
455
  tools=args.tools,
427
456
  provider_base_url=args.provider_base_url,
@@ -432,9 +461,11 @@ def main(argv: Sequence[str] | None = None) -> int:
432
461
  return _report_run(result, manager, json_output=args.json)
433
462
 
434
463
  if args.command == "resume":
464
+ prompt, prompt_parts = _read_prompt(args)
435
465
  result = AgentRunner(manager).resume(
436
466
  run_id=args.run_id,
437
- prompt=_read_prompt(args),
467
+ prompt=prompt,
468
+ prompt_parts=prompt_parts,
438
469
  timeout_seconds=args.timeout,
439
470
  artifacts=args.artifact,
440
471
  input_paths=args.input_paths,
@@ -461,7 +492,11 @@ def main(argv: Sequence[str] | None = None) -> int:
461
492
  print(f"aop: {error}", file=sys.stderr)
462
493
  return 2
463
494
  except FileNotFoundError as error:
464
- print(f"aop: command not found: {error.filename}", file=sys.stderr)
495
+ print(
496
+ f"aop: command not found: {error.filename} "
497
+ f"(PATH={os.environ.get('PATH', '')})",
498
+ file=sys.stderr,
499
+ )
465
500
  return 127
466
501
  except KeyboardInterrupt:
467
502
  print("aop: interrupted", file=sys.stderr)
@@ -472,6 +507,13 @@ def _add_prompt_arguments(parser: argparse.ArgumentParser) -> None:
472
507
  prompts = parser.add_mutually_exclusive_group(required=True)
473
508
  prompts.add_argument("--prompt")
474
509
  prompts.add_argument("--prompt-file", type=Path)
510
+ prompts.add_argument(
511
+ "--prompt-parts",
512
+ type=Path,
513
+ metavar="PATH",
514
+ help='JSON list of prompt blocks, each {"text": ..., "cache": true|false}; '
515
+ "Claude caches the prompt through the one cached block (claude only)",
516
+ )
475
517
 
476
518
 
477
519
  def _add_artifact_arguments(parser: argparse.ArgumentParser) -> None:
@@ -503,6 +545,25 @@ def _add_input_arguments(
503
545
  metavar="PATH",
504
546
  help=help_text,
505
547
  )
548
+ if resume:
549
+ return
550
+ parser.add_argument(
551
+ "--input-exclude",
552
+ action="append",
553
+ default=[],
554
+ dest="input_excludes",
555
+ metavar="GLOB",
556
+ help="leave matching paths out of every input directory; a pattern "
557
+ "without '/' matches names at any depth, one with '/' matches paths "
558
+ "from the input's root; repeat for more",
559
+ )
560
+ parser.add_argument(
561
+ "--skip-external-links",
562
+ action="store_true",
563
+ help="leave out symlinks that point outside their input instead of "
564
+ "failing; links that stay inside an input are always copied as their "
565
+ "targets",
566
+ )
506
567
 
507
568
 
508
569
  def _tool_list(value: str) -> tuple[str, ...]:
@@ -520,15 +581,36 @@ def _read_system_prompt(args: argparse.Namespace) -> str | None:
520
581
  ) from error
521
582
 
522
583
 
523
- def _read_prompt(args: argparse.Namespace) -> str:
584
+ def _read_prompt(args: argparse.Namespace) -> tuple[str, tuple[PromptPart, ...] | None]:
524
585
  if args.prompt is not None:
525
- return args.prompt
586
+ return args.prompt, None
587
+ path = args.prompt_file or args.prompt_parts
526
588
  try:
527
- return args.prompt_file.read_text()
589
+ text = path.read_text()
528
590
  except OSError as error:
591
+ raise AOPError(f"could not read prompt file {path}: {error}") from error
592
+ if args.prompt_file is not None:
593
+ return text, None
594
+ try:
595
+ blocks = json.loads(text)
596
+ except json.JSONDecodeError as error:
597
+ raise AOPError(f"prompt parts file {path} is not JSON: {error}") from error
598
+ if not isinstance(blocks, list) or not all(
599
+ isinstance(block, dict)
600
+ and set(block) <= {"text", "cache"}
601
+ and isinstance(block.get("text"), str)
602
+ and isinstance(block.get("cache", False), bool)
603
+ for block in blocks
604
+ ):
529
605
  raise AOPError(
530
- f"could not read prompt file {args.prompt_file}: {error}"
531
- ) from error
606
+ f"prompt parts file {path} must be a list of "
607
+ '{"text": string, "cache": boolean} objects'
608
+ )
609
+ parts = tuple(
610
+ PromptPart(chars=len(block["text"]), cache=block.get("cache", False))
611
+ for block in blocks
612
+ )
613
+ return "".join(block["text"] for block in blocks), parts
532
614
 
533
615
 
534
616
  def _report_policy(
@@ -574,6 +656,15 @@ def _report_policy(
574
656
  f"{prefix}inputs: {inputs['mode']} ({len(inputs.get('names', []))} declared)",
575
657
  file=stream,
576
658
  )
659
+ for item in inputs.get("planned", []):
660
+ skipped = sum(link["action"] == "skipped" for link in item["links"])
661
+ print(
662
+ f"{prefix} {item['name']}: {item['files']} files, "
663
+ f"{item['size_bytes'] / 1e6:.1f} MB; {len(item['excluded'])} paths "
664
+ f"excluded; {len(item['links']) - skipped} symlinks followed, "
665
+ f"{skipped} skipped",
666
+ file=stream,
667
+ )
577
668
  print(
578
669
  f"{prefix}inherited local instructions: {instructions['inherited_local']}; "
579
670
  f"provider built-in prompt: {instructions['provider_builtin']}; "
@@ -157,7 +157,7 @@ def resolve_policy(
157
157
  host={
158
158
  "access": selected.host_access,
159
159
  "runtime_paths": (
160
- ["/usr", "/bin", "/lib", "/lib64", "/etc runtime files"]
160
+ ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc runtime files"]
161
161
  if selected.host_access == "runtime-only"
162
162
  else ["/"]
163
163
  ),
@@ -28,11 +28,19 @@ class InputSnapshot:
28
28
  size_bytes: int
29
29
  sha256: str
30
30
  files: tuple[InputFile, ...]
31
+ # Paths left out of the snapshot, relative to the input: matches of the
32
+ # run's input excludes and AOP's own state directories.
33
+ excluded: tuple[str, ...] = ()
34
+ # Symlinks found in the input: {"path", "target", "action"}, where action
35
+ # is "followed" for a target inside the input and "skipped" otherwise.
36
+ links: tuple[dict[str, str], ...] = ()
31
37
 
32
38
  @classmethod
33
39
  def from_dict(cls, value: dict[str, Any]) -> InputSnapshot:
34
40
  fields = dict(value)
35
41
  fields["files"] = tuple(InputFile(**item) for item in fields.get("files", ()))
42
+ fields["excluded"] = tuple(fields.get("excluded", ()))
43
+ fields["links"] = tuple(fields.get("links", ()))
36
44
  return cls(**fields)
37
45
 
38
46
 
@@ -57,6 +65,15 @@ class InferenceRoute:
57
65
  return cls(**fields)
58
66
 
59
67
 
68
+ @dataclass(frozen=True)
69
+ class PromptPart:
70
+ """One content block of a prompt; `cache` asks Claude to cache the prompt
71
+ up to and including this block. The text itself stays in RunRequest.prompt."""
72
+
73
+ chars: int
74
+ cache: bool = False
75
+
76
+
60
77
  @dataclass(frozen=True)
61
78
  class RunRequest:
62
79
  run_id: str
@@ -90,6 +107,13 @@ class RunRequest:
90
107
  allowed_urls: tuple[str, ...] = ()
91
108
  # A sealed run's private, initially empty /workspace is writable.
92
109
  writable_workspace: bool = False
110
+ # The prompt split into content blocks, in order; their lengths add up to
111
+ # len(prompt). None sends the prompt as one block.
112
+ prompt_parts: tuple[PromptPart, ...] | None = None
113
+ # Glob patterns left out of every input directory, and whether symlinks
114
+ # that leave their input are skipped instead of failing the run.
115
+ input_excludes: tuple[str, ...] = ()
116
+ skip_external_links: bool = False
93
117
  # How `aop migrate-results` converted an older record, if it did.
94
118
  migration: dict[str, Any] | None = None
95
119
 
@@ -106,6 +130,11 @@ class RunRequest:
106
130
  if fields.get("tools") is not None:
107
131
  fields["tools"] = tuple(fields["tools"])
108
132
  fields["allowed_urls"] = tuple(fields.get("allowed_urls", ()))
133
+ fields["input_excludes"] = tuple(fields.get("input_excludes", ()))
134
+ if fields.get("prompt_parts") is not None:
135
+ fields["prompt_parts"] = tuple(
136
+ PromptPart(**part) for part in fields["prompt_parts"]
137
+ )
109
138
  fields["inputs"] = tuple(
110
139
  InputSnapshot.from_dict(item) for item in fields["inputs"]
111
140
  )
@@ -1,11 +1,14 @@
1
1
  """Prompt transport independent of the OS argument and environment limits."""
2
2
 
3
+ from collections.abc import Sequence
3
4
  from contextlib import contextmanager
4
5
  import json
5
6
  import os
6
7
  from pathlib import Path
7
8
  import tempfile
8
9
 
10
+ from .models import PromptPart
11
+
9
12
 
10
13
  # Native protocols remain in their adapters. Runtime-file is only for CLIs
11
14
  # whose one-shot parser accepts text exclusively as an argument.
@@ -47,6 +50,28 @@ def stdin_prompt(provider: str, prompt: str) -> str | None:
47
50
  return None
48
51
 
49
52
 
53
+ def claude_message(prompt: str, parts: Sequence[PromptPart]) -> str:
54
+ """A stream-json user message whose content blocks follow `parts`.
55
+
56
+ Claude Code marks its own system blocks and the context it appends after
57
+ the prompt with one-hour cache marks, and a five-minute mark must not
58
+ precede a one-hour one, so a caller's mark uses one hour too.
59
+ """
60
+ content = []
61
+ start = 0
62
+ for part in parts:
63
+ block: dict[str, object] = {
64
+ "type": "text",
65
+ "text": prompt[start : start + part.chars],
66
+ }
67
+ if part.cache:
68
+ block["cache_control"] = {"type": "ephemeral", "ttl": "1h"}
69
+ content.append(block)
70
+ start += part.chars
71
+ message = {"type": "user", "message": {"role": "user", "content": content}}
72
+ return json.dumps(message) + "\n"
73
+
74
+
50
75
  @contextmanager
51
76
  def prompt_stream(prompt: str | None):
52
77
  """A seekable stdin avoids blocking on pipe writes before the timeout starts."""
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import re
6
7
  import subprocess
7
8
 
@@ -24,6 +25,11 @@ def require_supported_agy(binary: str) -> str:
24
25
  capture_output=True,
25
26
  timeout=5,
26
27
  )
28
+ except FileNotFoundError as error:
29
+ raise AOPError(
30
+ f"{binary} not found on PATH ({os.environ.get('PATH', '')}); set "
31
+ "AOP_AGY_BIN to its absolute path"
32
+ ) from error
27
33
  except OSError as error:
28
34
  raise AOPError(f"could not query Agy version: {error}") from error
29
35
  except subprocess.TimeoutExpired as error:
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import ast
6
6
  import contextlib
7
+ import fnmatch
7
8
  import hashlib
8
9
  import json
9
10
  import os
@@ -39,6 +40,7 @@ from .models import (
39
40
  InputFile,
40
41
  InputSnapshot,
41
42
  InferenceRoute,
43
+ PromptPart,
42
44
  RunArtifact,
43
45
  RunRequest,
44
46
  RunResult,
@@ -1074,7 +1076,10 @@ def _capture_process(
1074
1076
  prompt: str | None,
1075
1077
  timeout_seconds: float | None,
1076
1078
  is_response: Callable[[str], bool],
1079
+ live_dir: Path | None = None,
1077
1080
  ) -> _ProcessCapture:
1081
+ """Run a harness to completion; with `live_dir`, its stdout and stderr also
1082
+ grow in live_dir/events.jsonl and live_dir/stderr.log while it runs."""
1078
1083
  started = time.monotonic()
1079
1084
  timed_out = False
1080
1085
  first_event_seconds = None
@@ -1105,6 +1110,11 @@ def _capture_process(
1105
1110
 
1106
1111
  stdout_parts: list[str] = []
1107
1112
  stderr_parts: list[str] = []
1113
+ live = contextlib.ExitStack()
1114
+ live_stdout = live_stderr = None
1115
+ if live_dir is not None:
1116
+ live_stdout = live.enter_context((live_dir / "events.jsonl").open("w"))
1117
+ live_stderr = live.enter_context((live_dir / "stderr.log").open("w"))
1108
1118
 
1109
1119
  def read_stdout() -> None:
1110
1120
  nonlocal first_event_seconds, first_response_seconds
@@ -1116,10 +1126,17 @@ def _capture_process(
1116
1126
  if first_response_seconds is None and is_response(line):
1117
1127
  first_response_seconds = elapsed
1118
1128
  stdout_parts.append(line)
1129
+ if live_stdout is not None:
1130
+ live_stdout.write(line)
1131
+ live_stdout.flush()
1119
1132
 
1120
1133
  def read_stderr() -> None:
1121
1134
  assert process.stderr is not None
1122
- stderr_parts.extend(process.stderr)
1135
+ for line in process.stderr:
1136
+ stderr_parts.append(line)
1137
+ if live_stderr is not None:
1138
+ live_stderr.write(line)
1139
+ live_stderr.flush()
1123
1140
 
1124
1141
  stdout_reader = threading.Thread(target=read_stdout, daemon=True)
1125
1142
  stderr_reader = threading.Thread(target=read_stderr, daemon=True)
@@ -1149,6 +1166,8 @@ def _capture_process(
1149
1166
  _close_finished_streams(
1150
1167
  (stdout_reader, process.stdout), (stderr_reader, process.stderr)
1151
1168
  )
1169
+ if not (stdout_reader.is_alive() or stderr_reader.is_alive()):
1170
+ live.close()
1152
1171
  return _ProcessCapture(
1153
1172
  stdout="".join(stdout_parts),
1154
1173
  stderr="".join(stderr_parts),
@@ -1221,9 +1240,14 @@ class ClaudeAdapter:
1221
1240
  command,
1222
1241
  cwd=worktree.path,
1223
1242
  environment=environment,
1224
- prompt=prompt_transport.stdin_prompt(self.provider, request.prompt),
1243
+ prompt=(
1244
+ prompt_transport.claude_message(request.prompt, request.prompt_parts)
1245
+ if request.prompt_parts is not None
1246
+ else prompt_transport.stdin_prompt(self.provider, request.prompt)
1247
+ ),
1225
1248
  timeout_seconds=request.timeout_seconds,
1226
1249
  is_response=self._is_response,
1250
+ live_dir=run_dir,
1227
1251
  )
1228
1252
  _atomic_write(run_dir / "events.jsonl", capture.stdout)
1229
1253
  _atomic_write(run_dir / "stderr.log", capture.stderr)
@@ -1396,6 +1420,8 @@ class ClaudeAdapter:
1396
1420
  command.extend(["--system-prompt", request.system_prompt])
1397
1421
  if request.thinking_display is not None:
1398
1422
  command.extend(["--thinking-display", request.thinking_display])
1423
+ if request.prompt_parts is not None:
1424
+ command.extend(["--input-format", "stream-json"])
1399
1425
  if request.session_id:
1400
1426
  command.extend(["--resume", request.session_id])
1401
1427
  else:
@@ -1861,6 +1887,7 @@ class GrokAdapter:
1861
1887
  prompt=None,
1862
1888
  timeout_seconds=request.timeout_seconds,
1863
1889
  is_response=self._is_response,
1890
+ live_dir=run_dir,
1864
1891
  )
1865
1892
  _atomic_write(run_dir / "events.jsonl", capture.stdout)
1866
1893
  _atomic_write(run_dir / "stderr.log", capture.stderr)
@@ -2194,6 +2221,7 @@ class CursorAdapter:
2194
2221
  prompt=prompt_transport.stdin_prompt(self.provider, request.prompt),
2195
2222
  timeout_seconds=request.timeout_seconds,
2196
2223
  is_response=self._is_response,
2224
+ live_dir=run_dir,
2197
2225
  )
2198
2226
  _atomic_write(run_dir / "events.jsonl", capture.stdout)
2199
2227
  _atomic_write(run_dir / "stderr.log", capture.stderr)
@@ -2412,6 +2440,7 @@ class DevinAdapter:
2412
2440
  prompt=None,
2413
2441
  timeout_seconds=request.timeout_seconds,
2414
2442
  is_response=lambda line: bool(line.strip()),
2443
+ live_dir=run_dir,
2415
2444
  )
2416
2445
  _atomic_write(run_dir / "events.jsonl", capture.stdout)
2417
2446
  _atomic_write(run_dir / "stderr.log", capture.stderr)
@@ -2673,6 +2702,7 @@ class OpenCodeAdapter:
2673
2702
  prompt=prompt_transport.stdin_prompt(self.provider, request.prompt),
2674
2703
  timeout_seconds=request.timeout_seconds,
2675
2704
  is_response=self._is_response,
2705
+ live_dir=run_dir,
2676
2706
  )
2677
2707
  _atomic_write(run_dir / "events.jsonl", capture.stdout)
2678
2708
  _atomic_write(run_dir / "stderr.log", capture.stderr)
@@ -3033,6 +3063,7 @@ class AgyAdapter:
3033
3063
  prompt=prompt_transport.stdin_prompt(self.provider, request.prompt),
3034
3064
  timeout_seconds=request.timeout_seconds,
3035
3065
  is_response=self._is_response,
3066
+ live_dir=run_dir,
3036
3067
  )
3037
3068
  _atomic_write(run_dir / "events.jsonl", capture.stdout)
3038
3069
  _atomic_write(run_dir / "stderr.log", capture.stderr)
@@ -3317,6 +3348,7 @@ class HermesAdapter:
3317
3348
  prompt=None,
3318
3349
  timeout_seconds=request.timeout_seconds,
3319
3350
  is_response=lambda line: bool(line.strip()),
3351
+ live_dir=run_dir,
3320
3352
  )
3321
3353
  _atomic_write(run_dir / "events.jsonl", capture.stdout)
3322
3354
  _atomic_write(run_dir / "stderr.log", capture.stderr)
@@ -3765,6 +3797,7 @@ class DeepSeekHarnessAdapter:
3765
3797
  prompt=None,
3766
3798
  timeout_seconds=request.timeout_seconds,
3767
3799
  is_response=self._is_response,
3800
+ live_dir=run_dir,
3768
3801
  )
3769
3802
  _atomic_write(run_dir / "events.jsonl", capture.stdout)
3770
3803
  _atomic_write(run_dir / "stderr.log", capture.stderr)
@@ -4205,7 +4238,7 @@ def _isolated_root_command(bwrap: str) -> list[str]:
4205
4238
  "--tmpfs",
4206
4239
  "/tmp",
4207
4240
  ]
4208
- for path in ("/usr", "/bin", "/lib", "/lib64"):
4241
+ for path in ("/usr", "/bin", "/sbin", "/lib", "/lib64"):
4209
4242
  source = Path(path)
4210
4243
  if source.exists():
4211
4244
  command.extend(["--ro-bind", path, path])
@@ -4214,6 +4247,9 @@ def _isolated_root_command(bwrap: str) -> list[str]:
4214
4247
  "/etc/ca-certificates",
4215
4248
  "/etc/crypto-policies",
4216
4249
  "/etc/hosts",
4250
+ # The dynamic linker finds libraries in folders registered under
4251
+ # /etc/ld.so.conf.d only through this cache.
4252
+ "/etc/ld.so.cache",
4217
4253
  "/etc/localtime",
4218
4254
  "/etc/nsswitch.conf",
4219
4255
  "/etc/pki",
@@ -5775,8 +5811,20 @@ class AgentRunner:
5775
5811
  allowed_urls: Sequence[str] = (),
5776
5812
  writable_workspace: bool = False,
5777
5813
  thinking_display: str | None = None,
5814
+ prompt_parts: Sequence[PromptPart] | None = None,
5815
+ input_excludes: Sequence[str] = (),
5816
+ skip_external_links: bool = False,
5778
5817
  ) -> RunResult:
5779
5818
  self._validate_mode(mode)
5819
+ prompt_parts = self._validated_prompt_parts(prompt, prompt_parts)
5820
+ input_excludes = tuple(input_excludes)
5821
+ # Every input problem fails here, before any state is created.
5822
+ plan_inputs(
5823
+ input_paths,
5824
+ excludes=input_excludes,
5825
+ skip_external_links=skip_external_links,
5826
+ protected=self._state_roots(),
5827
+ )
5780
5828
  if writable_workspace and profile != "sealed":
5781
5829
  raise AOPError("--writable-workspace applies only to the sealed profile")
5782
5830
  if thinking_display is not None:
@@ -5822,6 +5870,9 @@ class AgentRunner:
5822
5870
  allowed_urls=allowed_urls,
5823
5871
  writable_workspace=writable_workspace,
5824
5872
  thinking_display=thinking_display,
5873
+ prompt_parts=prompt_parts,
5874
+ input_excludes=input_excludes,
5875
+ skip_external_links=skip_external_links,
5825
5876
  )
5826
5877
  worktree = (
5827
5878
  self._create_sealed_workspace(request.run_id)
@@ -5854,6 +5905,7 @@ class AgentRunner:
5854
5905
  timeout_seconds: float | None = None,
5855
5906
  artifacts: Sequence[str] = (),
5856
5907
  input_paths: Sequence[str | os.PathLike[str]] | None = None,
5908
+ prompt_parts: Sequence[PromptPart] | None = None,
5857
5909
  _task_lock_held: bool = False,
5858
5910
  ) -> RunResult:
5859
5911
  parent_request = self.store.load_request(run_id)
@@ -5865,6 +5917,7 @@ class AgentRunner:
5865
5917
  )
5866
5918
  self.adapter = adapter_for(parent_request.provider)
5867
5919
  self._validate_mode(parent_request.mode)
5920
+ prompt_parts = self._validated_prompt_parts(prompt, prompt_parts)
5868
5921
  self._validate_no_web(
5869
5922
  parent_request.no_web, parent_request.mode, parent_request.profile
5870
5923
  )
@@ -5952,6 +6005,9 @@ class AgentRunner:
5952
6005
  allowed_urls=parent_request.allowed_urls,
5953
6006
  writable_workspace=parent_request.writable_workspace,
5954
6007
  thinking_display=parent_request.thinking_display,
6008
+ prompt_parts=prompt_parts,
6009
+ input_excludes=parent_request.input_excludes,
6010
+ skip_external_links=parent_request.skip_external_links,
5955
6011
  )
5956
6012
  return self._execute(
5957
6013
  request,
@@ -6068,7 +6124,12 @@ class AgentRunner:
6068
6124
  cache_dir = self.manager.cache_dir
6069
6125
  cache_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
6070
6126
  declared_inputs = _prepare_inputs(
6071
- input_paths,
6127
+ plan_inputs(
6128
+ input_paths,
6129
+ excludes=request.input_excludes,
6130
+ skip_external_links=request.skip_external_links,
6131
+ protected=self._state_roots() if input_provenance is None else (),
6132
+ ),
6072
6133
  input_dir,
6073
6134
  guest_paths=request.profile != "host",
6074
6135
  provenance=input_provenance,
@@ -6308,6 +6369,9 @@ class AgentRunner:
6308
6369
  allowed_urls: tuple[str, ...] = (),
6309
6370
  writable_workspace: bool = False,
6310
6371
  thinking_display: str | None = None,
6372
+ prompt_parts: tuple[PromptPart, ...] | None = None,
6373
+ input_excludes: tuple[str, ...] = (),
6374
+ skip_external_links: bool = False,
6311
6375
  ) -> RunRequest:
6312
6376
  return RunRequest(
6313
6377
  run_id=str(uuid.uuid4()),
@@ -6335,8 +6399,15 @@ class AgentRunner:
6335
6399
  allowed_urls=allowed_urls,
6336
6400
  writable_workspace=writable_workspace,
6337
6401
  thinking_display=thinking_display,
6402
+ prompt_parts=prompt_parts,
6403
+ input_excludes=input_excludes,
6404
+ skip_external_links=skip_external_links,
6338
6405
  )
6339
6406
 
6407
+ def _state_roots(self) -> tuple[Path, ...]:
6408
+ """AOP's own state, which no input snapshot may copy into itself."""
6409
+ return (self.manager.state_dir, self.manager.sealed_runtime_dir)
6410
+
6340
6411
  def _validate_provider_base_url(self, url: str | None, profile: str) -> None:
6341
6412
  variable = PROVIDER_BASE_URL_VARIABLES.get(self.adapter.provider)
6342
6413
  if url is None:
@@ -6356,6 +6427,27 @@ class AgentRunner:
6356
6427
  if parsed.scheme not in {"http", "https"} or not parsed.hostname:
6357
6428
  raise AOPError(f"--provider-base-url must be an http(s) URL: {url!r}")
6358
6429
 
6430
+ def _validated_prompt_parts(
6431
+ self, prompt: str, parts: Sequence[PromptPart] | None
6432
+ ) -> tuple[PromptPart, ...] | None:
6433
+ if parts is None:
6434
+ return None
6435
+ if self.adapter.provider != "claude":
6436
+ raise AOPError(f"{self.adapter.provider} does not support --prompt-parts")
6437
+ parts = tuple(parts)
6438
+ if not parts or any(part.chars <= 0 for part in parts):
6439
+ raise AOPError("--prompt-parts needs at least one part, none of them empty")
6440
+ if sum(part.chars for part in parts) != len(prompt):
6441
+ raise AOPError("prompt parts do not add up to the prompt")
6442
+ # Anthropic accepts four cache marks per request, and Claude Code
6443
+ # places three: two on its system prompt and one on the context it
6444
+ # appends after the prompt.
6445
+ if sum(part.cache for part in parts) > 1:
6446
+ raise AOPError(
6447
+ "Claude allows one cached prompt part; Claude Code uses the other marks"
6448
+ )
6449
+ return parts
6450
+
6359
6451
  def _validated_framing(
6360
6452
  self, system_prompt: str | None, tools: Sequence[str] | None, no_web: bool
6361
6453
  ) -> tuple[str, ...] | None:
@@ -6578,18 +6670,39 @@ def normalize_artifacts(artifacts: Sequence[str]) -> tuple[str, ...]:
6578
6670
  return tuple(normalized)
6579
6671
 
6580
6672
 
6581
- def _prepare_inputs(
6673
+ @dataclass(frozen=True)
6674
+ class InputPlan:
6675
+ """One declared input, checked and resolved before anything is copied."""
6676
+
6677
+ source: Path
6678
+ alias: str
6679
+ # (path relative to the input, host file to copy); empty for a file input.
6680
+ entries: tuple[tuple[str, Path], ...]
6681
+ excluded: tuple[str, ...]
6682
+ links: tuple[dict[str, str], ...]
6683
+
6684
+
6685
+ def plan_inputs(
6582
6686
  values: Sequence[str | os.PathLike[str]],
6583
- input_dir: Path,
6584
6687
  *,
6585
- guest_paths: bool = True,
6586
- provenance: Sequence[str] | None = None,
6587
- ) -> tuple[InputSnapshot, ...]:
6588
- if provenance is not None and len(provenance) != len(values):
6589
- raise AOPError("input provenance does not match the input snapshot set")
6590
- prepared: list[InputSnapshot] = []
6688
+ excludes: Sequence[str] = (),
6689
+ skip_external_links: bool = False,
6690
+ protected: Sequence[Path] = (),
6691
+ ) -> tuple[InputPlan, ...]:
6692
+ """Check every input as a run would, without copying.
6693
+
6694
+ Directories under `protected` (AOP's own state) are left out of any input
6695
+ that contains them. A symlink whose target lies inside its input is copied
6696
+ as the target's contents; one that leaves the input, or is dangling, fails
6697
+ the run unless `skip_external_links` is set.
6698
+ """
6699
+ for pattern in excludes:
6700
+ if not pattern or "\0" in pattern or pattern.startswith("/"):
6701
+ raise AOPError(f"input exclude must be a relative glob pattern: {pattern!r}")
6702
+ protected = tuple(path.resolve() for path in protected)
6703
+ plans: list[InputPlan] = []
6591
6704
  aliases: set[str] = set()
6592
- for index, value in enumerate(values):
6705
+ for value in values:
6593
6706
  raw = os.fspath(value)
6594
6707
  if not raw or "\0" in raw:
6595
6708
  raise AOPError("input path must be a non-empty filesystem path")
@@ -6609,46 +6722,136 @@ def _prepare_inputs(
6609
6722
  if alias in aliases:
6610
6723
  raise AOPError(f"input paths have the same basename: {alias}")
6611
6724
  aliases.add(alias)
6612
-
6613
- mounted = input_dir / alias
6725
+ if any(source == root or source.is_relative_to(root) for root in protected):
6726
+ raise AOPError(f"input path is inside AOP's state directory: {source}")
6614
6727
  if source.is_dir():
6615
- _copy_input_directory(source, mounted)
6728
+ plans.append(
6729
+ _plan_directory(source, alias, excludes, skip_external_links, protected)
6730
+ )
6731
+ elif source.is_file():
6732
+ plans.append(InputPlan(source, alias, (), (), ()))
6616
6733
  else:
6617
- shutil.copyfile(source, mounted)
6734
+ raise AOPError(f"input path is not a regular file or directory: {source}")
6735
+ return tuple(plans)
6736
+
6737
+
6738
+ def _plan_directory(
6739
+ root: Path,
6740
+ alias: str,
6741
+ excludes: Sequence[str],
6742
+ skip_external_links: bool,
6743
+ protected: tuple[Path, ...],
6744
+ ) -> InputPlan:
6745
+ entries: list[tuple[str, Path]] = []
6746
+ excluded: list[str] = []
6747
+ links: list[dict[str, str]] = []
6748
+
6749
+ def is_excluded(relative: str) -> bool:
6750
+ name = relative.rsplit("/", 1)[-1]
6751
+ return any(
6752
+ fnmatch.fnmatchcase(relative, pattern)
6753
+ or ("/" not in pattern and fnmatch.fnmatchcase(name, pattern))
6754
+ for pattern in excludes
6755
+ )
6756
+
6757
+ def is_protected(path: Path) -> bool:
6758
+ return any(path == item or path.is_relative_to(item) for item in protected)
6759
+
6760
+ def walk(directory: Path, relative: PurePosixPath, chain: tuple[Path, ...]) -> None:
6761
+ try:
6762
+ children = sorted(directory.iterdir(), key=lambda child: child.name)
6763
+ except OSError as error:
6764
+ raise AOPError(
6765
+ f"could not read input directory {directory}: {error}"
6766
+ ) from error
6767
+ for child in children:
6768
+ logical = (relative / child.name).as_posix()
6769
+ if is_excluded(logical):
6770
+ excluded.append(logical)
6771
+ continue
6772
+ status = child.lstat()
6773
+ path = child
6774
+ if stat.S_ISLNK(status.st_mode):
6775
+ target = child.resolve()
6776
+ inside = target.is_relative_to(root) and target.exists()
6777
+ if not inside:
6778
+ if not skip_external_links:
6779
+ raise AOPError(
6780
+ f"input contains a symlink that leaves it: {child} -> "
6781
+ f"{os.readlink(child)}; pass --skip-external-links "
6782
+ "to leave such links out, or --input-exclude to "
6783
+ "exclude them"
6784
+ )
6785
+ links.append(
6786
+ {"path": logical, "target": os.readlink(child), "action": "skipped"}
6787
+ )
6788
+ continue
6789
+ if target in chain:
6790
+ raise AOPError(f"input contains a symlink loop: {child}")
6791
+ links.append(
6792
+ {"path": logical, "target": os.readlink(child), "action": "followed"}
6793
+ )
6794
+ path = target
6795
+ status = target.stat()
6796
+ if stat.S_ISDIR(status.st_mode):
6797
+ resolved = path.resolve()
6798
+ if is_protected(resolved):
6799
+ excluded.append(logical)
6800
+ elif resolved in chain:
6801
+ raise AOPError(f"input contains a symlink loop: {child}")
6802
+ else:
6803
+ walk(path, PurePosixPath(logical), (*chain, resolved))
6804
+ elif stat.S_ISREG(status.st_mode):
6805
+ entries.append((logical, path))
6806
+ else:
6807
+ raise AOPError(f"read path contains a special file: {child}")
6808
+
6809
+ walk(root, PurePosixPath(), (root,))
6810
+ return InputPlan(root, alias, tuple(entries), tuple(excluded), tuple(links))
6811
+
6812
+
6813
+ def _prepare_inputs(
6814
+ plans: Sequence[InputPlan],
6815
+ input_dir: Path,
6816
+ *,
6817
+ guest_paths: bool = True,
6818
+ provenance: Sequence[str] | None = None,
6819
+ ) -> tuple[InputSnapshot, ...]:
6820
+ if provenance is not None and len(provenance) != len(plans):
6821
+ raise AOPError("input provenance does not match the input snapshot set")
6822
+ prepared: list[InputSnapshot] = []
6823
+ for index, plan in enumerate(plans):
6824
+ mounted = input_dir / plan.alias
6825
+ if plan.source.is_dir():
6826
+ mounted.mkdir()
6827
+ for relative, path in plan.entries:
6828
+ target = mounted / relative
6829
+ target.parent.mkdir(parents=True, exist_ok=True)
6830
+ shutil.copyfile(path, target)
6831
+ else:
6832
+ shutil.copyfile(plan.source, mounted)
6618
6833
  kind, files = _inspect_read_path(mounted)
6619
- size_bytes = sum(item.size_bytes for item in files)
6620
6834
  prepared.append(
6621
6835
  InputSnapshot(
6622
6836
  source_path=(
6623
- provenance[index] if provenance is not None else os.fspath(source)
6837
+ provenance[index]
6838
+ if provenance is not None
6839
+ else os.fspath(plan.source)
6624
6840
  ),
6625
6841
  mounted_path=(
6626
- f"/inputs/{alias}" if guest_paths else os.fspath(mounted)
6842
+ f"/inputs/{plan.alias}" if guest_paths else os.fspath(mounted)
6627
6843
  ),
6628
6844
  kind=kind,
6629
- size_bytes=size_bytes,
6845
+ size_bytes=sum(item.size_bytes for item in files),
6630
6846
  sha256=_read_path_digest(kind, files),
6631
6847
  files=files,
6848
+ excluded=plan.excluded,
6849
+ links=plan.links,
6632
6850
  )
6633
6851
  )
6634
6852
  return tuple(prepared)
6635
6853
 
6636
6854
 
6637
- def _copy_input_directory(source: Path, destination: Path) -> None:
6638
- destination.mkdir()
6639
- for child in sorted(source.iterdir(), key=lambda item: item.name):
6640
- status = child.lstat()
6641
- target = destination / child.name
6642
- if stat.S_ISLNK(status.st_mode):
6643
- raise AOPError(f"read path contains a symlink: {child}")
6644
- if stat.S_ISDIR(status.st_mode):
6645
- _copy_input_directory(child, target)
6646
- elif stat.S_ISREG(status.st_mode):
6647
- shutil.copyfile(child, target)
6648
- else:
6649
- raise AOPError(f"read path contains a special file: {child}")
6650
-
6651
-
6652
6855
  def _make_snapshot_private(root: Path) -> None:
6653
6856
  for directory, names, files in os.walk(root):
6654
6857
  path = Path(directory)