hyperloop 0.4.0__tar.gz → 0.5.0__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.
- hyperloop-0.5.0/CHANGELOG.md +30 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/PKG-INFO +3 -1
- {hyperloop-0.4.0 → hyperloop-0.5.0}/README.md +2 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/base/implementer.yaml +3 -1
- hyperloop-0.5.0/base/kustomization.yaml +7 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/base/pm.yaml +3 -1
- {hyperloop-0.4.0 → hyperloop-0.5.0}/base/process-improver.yaml +3 -1
- {hyperloop-0.4.0 → hyperloop-0.5.0}/base/process.yaml +3 -1
- {hyperloop-0.4.0 → hyperloop-0.5.0}/base/rebase-resolver.yaml +3 -1
- {hyperloop-0.4.0 → hyperloop-0.5.0}/base/verifier.yaml +3 -1
- {hyperloop-0.4.0 → hyperloop-0.5.0}/pyproject.toml +1 -1
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/cli.py +18 -26
- hyperloop-0.5.0/src/hyperloop/compose.py +288 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/config.py +7 -1
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_compose.py +167 -15
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_loop.py +3 -3
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_serial_agents.py +6 -6
- {hyperloop-0.4.0 → hyperloop-0.5.0}/uv.lock +1 -1
- hyperloop-0.4.0/CHANGELOG.md +0 -19
- hyperloop-0.4.0/src/hyperloop/compose.py +0 -126
- {hyperloop-0.4.0 → hyperloop-0.5.0}/.github/workflows/ci.yaml +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/.github/workflows/release.yaml +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/.gitignore +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/.pre-commit-config.yaml +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/.python-version +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/CLAUDE.md +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/specs/spec.md +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/__init__.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/__main__.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/adapters/__init__.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/adapters/git_state.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/adapters/local.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/adapters/serial.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/domain/__init__.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/domain/decide.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/domain/deps.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/domain/model.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/domain/pipeline.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/loop.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/ports/__init__.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/ports/pr.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/ports/runtime.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/ports/serial.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/ports/state.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/pr.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/__init__.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/fakes/__init__.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/fakes/pr.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/fakes/runtime.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/fakes/serial.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/fakes/state.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_cli.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_config.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_decide.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_deps.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_e2e.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_fakes.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_git_state.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_local_runtime.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_model.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_pipeline.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_pr.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_smoke.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_state_contract.py +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
3
|
+
<!-- version list -->
|
|
4
|
+
|
|
5
|
+
## v0.5.0 (2026-04-15)
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- Make base_ref configurable via config file and HYPERLOOP_BASE_REF env var
|
|
10
|
+
([`8efcc0f`](https://github.com/jsell-rh/hyperloop/commit/8efcc0f4292229525e4aa8831561982ad5384220))
|
|
11
|
+
|
|
12
|
+
- Replace local base/ loading with kustomize-based prompt composition
|
|
13
|
+
([`9644afb`](https://github.com/jsell-rh/hyperloop/commit/9644afb89a96eee564f997222d86201312bcb63e))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## v0.4.0 (2026-04-15)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## v0.3.0 (2026-04-15)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## v0.2.0 (2026-04-15)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
## v0.1.1 (2026-04-15)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## v0.1.0 (2026-04-15)
|
|
29
|
+
|
|
30
|
+
- Initial Release
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyperloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Orchestrator that walks tasks through composable process pipelines using AI agents
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Requires-Dist: pyyaml>=6.0.3
|
|
@@ -21,6 +21,7 @@ Walks tasks through composable process pipelines using AI agents. You write spec
|
|
|
21
21
|
|
|
22
22
|
- Python 3.12+
|
|
23
23
|
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
|
24
|
+
- [kustomize](https://kubectl.docs.kubernetes.io/installation/kustomize/) (`kustomize` on PATH) — resolves agent definitions at startup
|
|
24
25
|
- `git`
|
|
25
26
|
- `gh` CLI (optional, for GitHub PR operations)
|
|
26
27
|
|
|
@@ -222,6 +223,7 @@ All task state is tracked in git. Every commit includes `Spec-Ref` and `Task-Ref
|
|
|
222
223
|
# .hyperloop.yaml
|
|
223
224
|
|
|
224
225
|
overlay: .hyperloop/agents/ # local path or git URL to kustomization dir
|
|
226
|
+
base_ref: github.com/jsell-rh/hyperloop//base?ref=v1.0.0 # kustomize remote base (env: HYPERLOOP_BASE_REF)
|
|
225
227
|
|
|
226
228
|
target:
|
|
227
229
|
repo: owner/repo # GitHub repo (default: inferred from git remote)
|
|
@@ -6,6 +6,7 @@ Walks tasks through composable process pipelines using AI agents. You write spec
|
|
|
6
6
|
|
|
7
7
|
- Python 3.12+
|
|
8
8
|
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
|
9
|
+
- [kustomize](https://kubectl.docs.kubernetes.io/installation/kustomize/) (`kustomize` on PATH) — resolves agent definitions at startup
|
|
9
10
|
- `git`
|
|
10
11
|
- `gh` CLI (optional, for GitHub PR operations)
|
|
11
12
|
|
|
@@ -207,6 +208,7 @@ All task state is tracked in git. Every commit includes `Spec-Ref` and `Task-Ref
|
|
|
207
208
|
# .hyperloop.yaml
|
|
208
209
|
|
|
209
210
|
overlay: .hyperloop/agents/ # local path or git URL to kustomization dir
|
|
211
|
+
base_ref: github.com/jsell-rh/hyperloop//base?ref=v1.0.0 # kustomize remote base (env: HYPERLOOP_BASE_REF)
|
|
210
212
|
|
|
211
213
|
target:
|
|
212
214
|
repo: owner/repo # GitHub repo (default: inferred from git remote)
|
|
@@ -54,6 +54,7 @@ def _config_table(cfg: Config) -> Table:
|
|
|
54
54
|
table.add_row("base_branch", cfg.base_branch)
|
|
55
55
|
table.add_row("specs_dir", cfg.specs_dir)
|
|
56
56
|
table.add_row("overlay", cfg.overlay or "[dim]none[/dim]")
|
|
57
|
+
table.add_row("base_ref", cfg.base_ref)
|
|
57
58
|
table.add_row("runtime", cfg.runtime)
|
|
58
59
|
table.add_row("max_workers", str(cfg.max_workers))
|
|
59
60
|
table.add_row("auto_merge", str(cfg.auto_merge))
|
|
@@ -66,35 +67,26 @@ def _config_table(cfg: Config) -> Table:
|
|
|
66
67
|
return table
|
|
67
68
|
|
|
68
69
|
|
|
69
|
-
def _make_composer(state: object) -> object
|
|
70
|
-
"""Construct a PromptComposer
|
|
70
|
+
def _make_composer(cfg: object, state: object) -> object:
|
|
71
|
+
"""Construct a PromptComposer via kustomize build.
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
2. Installed package: via importlib.resources
|
|
73
|
+
Runs ``kustomize build`` on the configured overlay (or the default
|
|
74
|
+
hyperloop base if no overlay is set).
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
# Development path: relative to this source file
|
|
81
|
-
dev_base = Path(__file__).resolve().parent.parent.parent / "base"
|
|
82
|
-
if dev_base.is_dir():
|
|
83
|
-
return PromptComposer(base_dir=dev_base, state=state) # type: ignore[arg-type]
|
|
76
|
+
Args:
|
|
77
|
+
cfg: Config object with an ``overlay`` attribute.
|
|
78
|
+
state: StateStore instance for reading process overlays at spawn time.
|
|
84
79
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
80
|
+
Returns:
|
|
81
|
+
A PromptComposer with resolved templates.
|
|
82
|
+
"""
|
|
83
|
+
from hyperloop.compose import PromptComposer, check_kustomize_available
|
|
88
84
|
|
|
89
|
-
|
|
90
|
-
base_path = Path(str(ref))
|
|
91
|
-
if base_path.is_dir():
|
|
92
|
-
return PromptComposer(base_dir=base_path, state=state) # type: ignore[arg-type]
|
|
93
|
-
except (ImportError, TypeError, FileNotFoundError):
|
|
94
|
-
pass
|
|
85
|
+
check_kustomize_available()
|
|
95
86
|
|
|
96
|
-
|
|
97
|
-
|
|
87
|
+
overlay = getattr(cfg, "overlay", None)
|
|
88
|
+
base_ref = getattr(cfg, "base_ref", "github.com/jsell-rh/hyperloop//base?ref=main")
|
|
89
|
+
return PromptComposer.from_kustomize(overlay, state, base_ref=base_ref) # type: ignore[arg-type]
|
|
98
90
|
|
|
99
91
|
|
|
100
92
|
@app.command()
|
|
@@ -197,8 +189,8 @@ def run(
|
|
|
197
189
|
runtime = LocalRuntime(repo_path=str(repo_path))
|
|
198
190
|
serial_runner = SubprocessSerialRunner(repo_path=str(repo_path))
|
|
199
191
|
|
|
200
|
-
# Resolve
|
|
201
|
-
composer = _make_composer(state)
|
|
192
|
+
# Resolve agent definitions via kustomize build
|
|
193
|
+
composer = _make_composer(cfg, state)
|
|
202
194
|
|
|
203
195
|
def _on_cycle(summary: dict[str, object]) -> None:
|
|
204
196
|
"""Print a rich status line after each orchestrator cycle."""
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Prompt composition — kustomize-resolved templates + runtime context.
|
|
2
|
+
|
|
3
|
+
Three layers composed at spawn time:
|
|
4
|
+
1. Base + project overlay via kustomize (resolved at startup)
|
|
5
|
+
2. Process overlay from specs/prompts/{role}-overlay.yaml (injected at spawn time)
|
|
6
|
+
3. Task context: spec content, findings, traceability refs (spec_ref, task_id)
|
|
7
|
+
|
|
8
|
+
The orchestrator runs ``kustomize build`` at startup to resolve layers 1+2 into
|
|
9
|
+
AgentTemplate objects. Layer 3 (process overlay + task context) is injected at
|
|
10
|
+
spawn time because it changes during the loop.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import tempfile
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, cast
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from hyperloop.ports.state import StateStore
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
HYPERLOOP_BASE_REF = "github.com/jsell-rh/hyperloop//base?ref=main"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class AgentTemplate:
|
|
35
|
+
"""A resolved agent definition from kustomize build."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
prompt: str
|
|
39
|
+
annotations: dict[str, str]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PromptComposer:
|
|
43
|
+
"""Composes agent prompts from kustomize-resolved templates + runtime context."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, templates: dict[str, AgentTemplate], state: StateStore) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Args:
|
|
48
|
+
templates: role name -> resolved agent definition (from kustomize build).
|
|
49
|
+
state: StateStore for reading process overlays and spec files at spawn time.
|
|
50
|
+
"""
|
|
51
|
+
self._templates = templates
|
|
52
|
+
self._state = state
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_kustomize(
|
|
56
|
+
cls,
|
|
57
|
+
overlay: str | None,
|
|
58
|
+
state: StateStore,
|
|
59
|
+
base_ref: str = HYPERLOOP_BASE_REF,
|
|
60
|
+
) -> PromptComposer:
|
|
61
|
+
"""Resolve templates via kustomize build, then construct.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
overlay: Path or git URL to a kustomization directory. If None,
|
|
65
|
+
a temporary kustomization referencing the hyperloop base
|
|
66
|
+
is created and built.
|
|
67
|
+
state: StateStore for reading process overlays at spawn time.
|
|
68
|
+
base_ref: Kustomize remote resource for the base definitions.
|
|
69
|
+
Configurable via .hyperloop.yaml or HYPERLOOP_BASE_REF env var.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
A PromptComposer with resolved templates.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
RuntimeError: If kustomize build fails.
|
|
76
|
+
"""
|
|
77
|
+
raw_yaml = _kustomize_build(overlay, base_ref=base_ref)
|
|
78
|
+
templates = _parse_multi_doc(raw_yaml)
|
|
79
|
+
return cls(templates, state)
|
|
80
|
+
|
|
81
|
+
def compose(
|
|
82
|
+
self,
|
|
83
|
+
role: str,
|
|
84
|
+
task_id: str,
|
|
85
|
+
spec_ref: str,
|
|
86
|
+
findings: str,
|
|
87
|
+
) -> str:
|
|
88
|
+
"""Compose the full prompt for a worker.
|
|
89
|
+
|
|
90
|
+
Layers:
|
|
91
|
+
1. Resolved template prompt (from kustomize, includes base + project overlay)
|
|
92
|
+
2. Process overlay from specs/prompts/{role}-overlay.yaml (if exists)
|
|
93
|
+
3. Task context: spec content, findings, traceability refs
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
role: Agent role name (e.g. "implementer", "verifier").
|
|
97
|
+
task_id: Task identifier (e.g. "task-027").
|
|
98
|
+
spec_ref: Path to the originating spec file (e.g. "specs/persistence.md").
|
|
99
|
+
findings: Findings from prior rounds (empty string if none).
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
The composed prompt string ready to pass to a worker.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ValueError: If the role has no resolved template.
|
|
106
|
+
"""
|
|
107
|
+
if role not in self._templates:
|
|
108
|
+
msg = f"Unknown role '{role}': no agent template resolved for this role"
|
|
109
|
+
raise ValueError(msg)
|
|
110
|
+
|
|
111
|
+
template = self._templates[role]
|
|
112
|
+
prompt = template.prompt.replace("{spec_ref}", spec_ref).replace("{task_id}", task_id)
|
|
113
|
+
|
|
114
|
+
# Layer 2: Process overlay (from target repo specs/prompts/)
|
|
115
|
+
overlay_path = f"specs/prompts/{role}-overlay.yaml"
|
|
116
|
+
overlay_content = self._state.read_file(overlay_path)
|
|
117
|
+
overlay_text = ""
|
|
118
|
+
if overlay_content is not None:
|
|
119
|
+
overlay_text = self._extract_overlay_prompt(overlay_content)
|
|
120
|
+
|
|
121
|
+
# Layer 3: Task context — spec content
|
|
122
|
+
spec_content = self._state.read_file(spec_ref)
|
|
123
|
+
|
|
124
|
+
# Assemble the final prompt
|
|
125
|
+
sections: list[str] = [prompt.rstrip()]
|
|
126
|
+
|
|
127
|
+
if overlay_text:
|
|
128
|
+
sections.append(f"## Process Overlay\n{overlay_text}")
|
|
129
|
+
|
|
130
|
+
if spec_content is not None:
|
|
131
|
+
sections.append(f"## Spec\n{spec_content}")
|
|
132
|
+
else:
|
|
133
|
+
sections.append(
|
|
134
|
+
f"## Spec\n[Spec file '{spec_ref}' not found. Proceed with available context.]"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if findings:
|
|
138
|
+
sections.append(f"## Findings\n{findings}")
|
|
139
|
+
|
|
140
|
+
return "\n\n".join(sections) + "\n"
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _extract_overlay_prompt(raw_yaml: str) -> str:
|
|
144
|
+
"""Extract prompt or content from overlay YAML.
|
|
145
|
+
|
|
146
|
+
Overlay files may contain a 'prompt' field (like agent definitions)
|
|
147
|
+
or raw text content. Handles both.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
doc = yaml.safe_load(raw_yaml)
|
|
151
|
+
if isinstance(doc, dict) and "prompt" in doc:
|
|
152
|
+
return str(cast("dict[str, object]", doc)["prompt"]).strip()
|
|
153
|
+
except yaml.YAMLError:
|
|
154
|
+
pass
|
|
155
|
+
# Fall back to raw content
|
|
156
|
+
return raw_yaml.strip()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _kustomize_build(overlay: str | None, base_ref: str = HYPERLOOP_BASE_REF) -> str:
|
|
160
|
+
"""Run ``kustomize build`` and return the raw YAML output.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
overlay: Path or git URL to a kustomization directory.
|
|
164
|
+
If None, builds from a temporary kustomization that
|
|
165
|
+
references the hyperloop base.
|
|
166
|
+
base_ref: Kustomize remote resource for the base definitions.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
The multi-document YAML output from kustomize build.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
RuntimeError: If kustomize build exits non-zero.
|
|
173
|
+
"""
|
|
174
|
+
if overlay is not None:
|
|
175
|
+
return _run_kustomize(overlay)
|
|
176
|
+
|
|
177
|
+
# No overlay — build a temp kustomization referencing the base
|
|
178
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
179
|
+
kustomization = Path(tmp) / "kustomization.yaml"
|
|
180
|
+
kustomization.write_text(f"resources:\n - {base_ref}\n")
|
|
181
|
+
return _run_kustomize(tmp)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _run_kustomize(target: str) -> str:
|
|
185
|
+
"""Execute ``kustomize build <target>`` and return stdout.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
RuntimeError: If the command fails.
|
|
189
|
+
"""
|
|
190
|
+
result = subprocess.run(
|
|
191
|
+
["kustomize", "build", target],
|
|
192
|
+
capture_output=True,
|
|
193
|
+
text=True,
|
|
194
|
+
timeout=120,
|
|
195
|
+
)
|
|
196
|
+
if result.returncode != 0:
|
|
197
|
+
msg = f"kustomize build failed (exit {result.returncode}): {result.stderr.strip()}"
|
|
198
|
+
raise RuntimeError(msg)
|
|
199
|
+
return result.stdout
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _extract_name(doc: dict[str, object]) -> str:
|
|
203
|
+
"""Extract the resource name from a YAML document.
|
|
204
|
+
|
|
205
|
+
Supports both kustomize-style ``metadata.name`` and legacy top-level
|
|
206
|
+
``name`` for backward compatibility.
|
|
207
|
+
"""
|
|
208
|
+
metadata = doc.get("metadata")
|
|
209
|
+
if isinstance(metadata, dict) and "name" in metadata:
|
|
210
|
+
return str(metadata["name"])
|
|
211
|
+
return str(doc.get("name", ""))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _parse_multi_doc(raw: str) -> dict[str, AgentTemplate]:
|
|
215
|
+
"""Parse multi-document YAML output into AgentTemplate objects.
|
|
216
|
+
|
|
217
|
+
Only documents with ``kind: Agent`` are extracted. Process definitions
|
|
218
|
+
and other kinds are ignored for prompt composition purposes.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
raw: Multi-document YAML string from kustomize build.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
A dict mapping agent name -> AgentTemplate.
|
|
225
|
+
"""
|
|
226
|
+
templates: dict[str, AgentTemplate] = {}
|
|
227
|
+
for doc in yaml.safe_load_all(raw):
|
|
228
|
+
if not isinstance(doc, dict):
|
|
229
|
+
continue
|
|
230
|
+
if doc.get("kind") != "Agent":
|
|
231
|
+
continue
|
|
232
|
+
name = _extract_name(doc)
|
|
233
|
+
prompt = doc.get("prompt", "")
|
|
234
|
+
annotations = doc.get("annotations", {})
|
|
235
|
+
if not isinstance(annotations, dict):
|
|
236
|
+
annotations = {}
|
|
237
|
+
templates[name] = AgentTemplate(
|
|
238
|
+
name=name,
|
|
239
|
+
prompt=str(prompt),
|
|
240
|
+
annotations={str(k): str(v) for k, v in annotations.items()},
|
|
241
|
+
)
|
|
242
|
+
return templates
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def check_kustomize_available() -> None:
|
|
246
|
+
"""Check that kustomize is on PATH.
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
SystemExit: If kustomize is not found.
|
|
250
|
+
"""
|
|
251
|
+
if shutil.which("kustomize") is None:
|
|
252
|
+
msg = (
|
|
253
|
+
"Error: kustomize CLI not found. "
|
|
254
|
+
"Install it: https://kubectl.docs.kubernetes.io/installation/kustomize/"
|
|
255
|
+
)
|
|
256
|
+
raise SystemExit(msg)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def load_templates_from_dir(base_dir: str | Path) -> dict[str, AgentTemplate]:
|
|
260
|
+
"""Load agent templates directly from a directory of YAML files.
|
|
261
|
+
|
|
262
|
+
This is used for testing and as a fallback when kustomize is not available.
|
|
263
|
+
It reads all ``*.yaml`` files in ``base_dir`` and extracts Agent definitions.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
base_dir: Path to the directory containing base agent YAML files.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
A dict mapping agent name -> AgentTemplate.
|
|
270
|
+
"""
|
|
271
|
+
templates: dict[str, AgentTemplate] = {}
|
|
272
|
+
base_path = Path(base_dir)
|
|
273
|
+
for yaml_file in base_path.glob("*.yaml"):
|
|
274
|
+
if yaml_file.name == "kustomization.yaml":
|
|
275
|
+
continue
|
|
276
|
+
with open(yaml_file) as f:
|
|
277
|
+
doc = yaml.safe_load(f)
|
|
278
|
+
if doc and doc.get("kind") == "Agent" and "prompt" in doc:
|
|
279
|
+
name = _extract_name(doc)
|
|
280
|
+
annotations = doc.get("annotations", {})
|
|
281
|
+
if not isinstance(annotations, dict):
|
|
282
|
+
annotations = {}
|
|
283
|
+
templates[name] = AgentTemplate(
|
|
284
|
+
name=name,
|
|
285
|
+
prompt=doc["prompt"],
|
|
286
|
+
annotations={str(k): str(v) for k, v in annotations.items()},
|
|
287
|
+
)
|
|
288
|
+
return templates
|
|
@@ -6,11 +6,14 @@ returns a frozen Config dataclass. CLI arguments can override file values.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import os
|
|
9
10
|
from dataclasses import dataclass
|
|
10
11
|
from typing import TYPE_CHECKING, cast
|
|
11
12
|
|
|
12
13
|
import yaml
|
|
13
14
|
|
|
15
|
+
DEFAULT_BASE_REF = "github.com/jsell-rh/hyperloop//base?ref=main"
|
|
16
|
+
|
|
14
17
|
if TYPE_CHECKING:
|
|
15
18
|
from pathlib import Path
|
|
16
19
|
|
|
@@ -27,6 +30,7 @@ class Config:
|
|
|
27
30
|
base_branch: str # default: "main"
|
|
28
31
|
specs_dir: str # default: "specs"
|
|
29
32
|
overlay: str | None # path or git URL to kustomization dir
|
|
33
|
+
base_ref: str # kustomize remote base ref (env: HYPERLOOP_BASE_REF)
|
|
30
34
|
runtime: str # "local" (v1 only)
|
|
31
35
|
max_workers: int # default: 6
|
|
32
36
|
auto_merge: bool # default: True
|
|
@@ -44,6 +48,7 @@ def _defaults() -> dict[str, object]:
|
|
|
44
48
|
"base_branch": "main",
|
|
45
49
|
"specs_dir": "specs",
|
|
46
50
|
"overlay": None,
|
|
51
|
+
"base_ref": os.environ.get("HYPERLOOP_BASE_REF", DEFAULT_BASE_REF),
|
|
47
52
|
"runtime": "local",
|
|
48
53
|
"max_workers": 6,
|
|
49
54
|
"auto_merge": True,
|
|
@@ -63,7 +68,7 @@ def _flatten_yaml(raw: dict[str, object]) -> dict[str, object]:
|
|
|
63
68
|
flat: dict[str, object] = {}
|
|
64
69
|
|
|
65
70
|
# Top-level scalars
|
|
66
|
-
for key in ("overlay", "poll_interval", "max_rounds", "max_rebase_attempts"):
|
|
71
|
+
for key in ("overlay", "base_ref", "poll_interval", "max_rounds", "max_rebase_attempts"):
|
|
67
72
|
if key in raw:
|
|
68
73
|
flat[key] = raw[key]
|
|
69
74
|
|
|
@@ -150,6 +155,7 @@ def load_config(
|
|
|
150
155
|
base_branch=str(values["base_branch"]),
|
|
151
156
|
specs_dir=str(values["specs_dir"]),
|
|
152
157
|
overlay=values["overlay"] if values["overlay"] is not None else None, # type: ignore[arg-type]
|
|
158
|
+
base_ref=str(values["base_ref"]),
|
|
153
159
|
runtime=str(values["runtime"]),
|
|
154
160
|
max_workers=int(values["max_workers"]), # type: ignore[arg-type]
|
|
155
161
|
auto_merge=bool(values["auto_merge"]),
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
"""Tests for prompt composition —
|
|
1
|
+
"""Tests for prompt composition — resolved templates + overlay + task context.
|
|
2
2
|
|
|
3
|
-
Uses InMemoryStateStore with pre-loaded files
|
|
3
|
+
Uses InMemoryStateStore with pre-loaded files and pre-resolved AgentTemplate
|
|
4
|
+
objects. No kustomize dependency — unit tests skip the kustomize build step.
|
|
4
5
|
"""
|
|
5
6
|
|
|
6
7
|
from __future__ import annotations
|
|
7
8
|
|
|
9
|
+
import shutil
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
|
|
10
12
|
import pytest
|
|
11
13
|
|
|
12
|
-
from hyperloop.compose import PromptComposer
|
|
14
|
+
from hyperloop.compose import AgentTemplate, PromptComposer, load_templates_from_dir
|
|
13
15
|
from tests.fakes.state import InMemoryStateStore
|
|
14
16
|
|
|
15
17
|
# The base/ dir lives at the repo root, adjacent to src/
|
|
@@ -21,6 +23,11 @@ BASE_DIR = Path(__file__).parent.parent / "base"
|
|
|
21
23
|
# ---------------------------------------------------------------------------
|
|
22
24
|
|
|
23
25
|
|
|
26
|
+
def _templates() -> dict[str, AgentTemplate]:
|
|
27
|
+
"""Load base templates from the repo's base/ directory."""
|
|
28
|
+
return load_templates_from_dir(BASE_DIR)
|
|
29
|
+
|
|
30
|
+
|
|
24
31
|
def _state_with_spec(
|
|
25
32
|
spec_ref: str = "specs/widget.md", spec_content: str = "Build a widget."
|
|
26
33
|
) -> InMemoryStateStore:
|
|
@@ -40,7 +47,7 @@ class TestBasePromptOnly:
|
|
|
40
47
|
|
|
41
48
|
def test_compose_returns_base_prompt_content(self) -> None:
|
|
42
49
|
state = _state_with_spec()
|
|
43
|
-
composer = PromptComposer(
|
|
50
|
+
composer = PromptComposer(templates=_templates(), state=state)
|
|
44
51
|
|
|
45
52
|
result = composer.compose(
|
|
46
53
|
role="implementer",
|
|
@@ -54,7 +61,7 @@ class TestBasePromptOnly:
|
|
|
54
61
|
|
|
55
62
|
def test_compose_includes_spec_content(self) -> None:
|
|
56
63
|
state = _state_with_spec()
|
|
57
|
-
composer = PromptComposer(
|
|
64
|
+
composer = PromptComposer(templates=_templates(), state=state)
|
|
58
65
|
|
|
59
66
|
result = composer.compose(
|
|
60
67
|
role="implementer",
|
|
@@ -67,7 +74,7 @@ class TestBasePromptOnly:
|
|
|
67
74
|
|
|
68
75
|
def test_compose_includes_no_findings_when_empty(self) -> None:
|
|
69
76
|
state = _state_with_spec()
|
|
70
|
-
composer = PromptComposer(
|
|
77
|
+
composer = PromptComposer(templates=_templates(), state=state)
|
|
71
78
|
|
|
72
79
|
result = composer.compose(
|
|
73
80
|
role="implementer",
|
|
@@ -85,7 +92,7 @@ class TestTemplateVariables:
|
|
|
85
92
|
|
|
86
93
|
def test_spec_ref_is_replaced(self) -> None:
|
|
87
94
|
state = _state_with_spec()
|
|
88
|
-
composer = PromptComposer(
|
|
95
|
+
composer = PromptComposer(templates=_templates(), state=state)
|
|
89
96
|
|
|
90
97
|
result = composer.compose(
|
|
91
98
|
role="implementer",
|
|
@@ -99,7 +106,7 @@ class TestTemplateVariables:
|
|
|
99
106
|
|
|
100
107
|
def test_task_id_is_replaced(self) -> None:
|
|
101
108
|
state = _state_with_spec()
|
|
102
|
-
composer = PromptComposer(
|
|
109
|
+
composer = PromptComposer(templates=_templates(), state=state)
|
|
103
110
|
|
|
104
111
|
result = composer.compose(
|
|
105
112
|
role="implementer",
|
|
@@ -120,7 +127,7 @@ class TestProcessOverlay:
|
|
|
120
127
|
overlay_content = "prompt: |\n Always run linter before submitting.\n"
|
|
121
128
|
state.set_file("specs/prompts/implementer-overlay.yaml", overlay_content)
|
|
122
129
|
|
|
123
|
-
composer = PromptComposer(
|
|
130
|
+
composer = PromptComposer(templates=_templates(), state=state)
|
|
124
131
|
|
|
125
132
|
result = composer.compose(
|
|
126
133
|
role="implementer",
|
|
@@ -136,7 +143,7 @@ class TestProcessOverlay:
|
|
|
136
143
|
state = _state_with_spec()
|
|
137
144
|
# No overlay file set
|
|
138
145
|
|
|
139
|
-
composer = PromptComposer(
|
|
146
|
+
composer = PromptComposer(templates=_templates(), state=state)
|
|
140
147
|
|
|
141
148
|
result = composer.compose(
|
|
142
149
|
role="implementer",
|
|
@@ -154,7 +161,7 @@ class TestFindings:
|
|
|
154
161
|
|
|
155
162
|
def test_findings_are_appended(self) -> None:
|
|
156
163
|
state = _state_with_spec()
|
|
157
|
-
composer = PromptComposer(
|
|
164
|
+
composer = PromptComposer(templates=_templates(), state=state)
|
|
158
165
|
|
|
159
166
|
result = composer.compose(
|
|
160
167
|
role="implementer",
|
|
@@ -172,7 +179,7 @@ class TestUnknownRole:
|
|
|
172
179
|
|
|
173
180
|
def test_unknown_role_raises_value_error(self) -> None:
|
|
174
181
|
state = _state_with_spec()
|
|
175
|
-
composer = PromptComposer(
|
|
182
|
+
composer = PromptComposer(templates=_templates(), state=state)
|
|
176
183
|
|
|
177
184
|
with pytest.raises(ValueError, match=r"(?i)unknown.*role.*nonexistent"):
|
|
178
185
|
composer.compose(
|
|
@@ -189,7 +196,7 @@ class TestMissingSpecRef:
|
|
|
189
196
|
def test_missing_spec_still_composes(self) -> None:
|
|
190
197
|
state = InMemoryStateStore()
|
|
191
198
|
# No spec file set — spec_ref points to nothing
|
|
192
|
-
composer = PromptComposer(
|
|
199
|
+
composer = PromptComposer(templates=_templates(), state=state)
|
|
193
200
|
|
|
194
201
|
result = composer.compose(
|
|
195
202
|
role="implementer",
|
|
@@ -203,7 +210,7 @@ class TestMissingSpecRef:
|
|
|
203
210
|
|
|
204
211
|
def test_missing_spec_notes_absence(self) -> None:
|
|
205
212
|
state = InMemoryStateStore()
|
|
206
|
-
composer = PromptComposer(
|
|
213
|
+
composer = PromptComposer(templates=_templates(), state=state)
|
|
207
214
|
|
|
208
215
|
result = composer.compose(
|
|
209
216
|
role="implementer",
|
|
@@ -228,7 +235,7 @@ class TestAllRoles:
|
|
|
228
235
|
)
|
|
229
236
|
def test_all_base_roles_compose(self, role: str) -> None:
|
|
230
237
|
state = _state_with_spec()
|
|
231
|
-
composer = PromptComposer(
|
|
238
|
+
composer = PromptComposer(templates=_templates(), state=state)
|
|
232
239
|
|
|
233
240
|
result = composer.compose(
|
|
234
241
|
role=role,
|
|
@@ -238,3 +245,148 @@ class TestAllRoles:
|
|
|
238
245
|
)
|
|
239
246
|
|
|
240
247
|
assert len(result) > 0
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class TestLoadTemplatesFromDir:
|
|
251
|
+
"""load_templates_from_dir reads YAML files and builds AgentTemplate objects."""
|
|
252
|
+
|
|
253
|
+
def test_loads_all_base_agents(self) -> None:
|
|
254
|
+
templates = load_templates_from_dir(BASE_DIR)
|
|
255
|
+
assert "implementer" in templates
|
|
256
|
+
assert "verifier" in templates
|
|
257
|
+
assert "pm" in templates
|
|
258
|
+
assert "process-improver" in templates
|
|
259
|
+
assert "rebase-resolver" in templates
|
|
260
|
+
|
|
261
|
+
def test_skips_non_agent_kinds(self) -> None:
|
|
262
|
+
"""Process definitions (kind: Process) are not loaded as templates."""
|
|
263
|
+
templates = load_templates_from_dir(BASE_DIR)
|
|
264
|
+
assert "default" not in templates # process.yaml has name: default
|
|
265
|
+
|
|
266
|
+
def test_template_has_prompt(self) -> None:
|
|
267
|
+
templates = load_templates_from_dir(BASE_DIR)
|
|
268
|
+
impl = templates["implementer"]
|
|
269
|
+
assert "You are a worker agent implementing a task" in impl.prompt
|
|
270
|
+
|
|
271
|
+
def test_template_has_annotations(self) -> None:
|
|
272
|
+
templates = load_templates_from_dir(BASE_DIR)
|
|
273
|
+
impl = templates["implementer"]
|
|
274
|
+
assert "ambient.io/persona" in impl.annotations
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class TestAgentTemplate:
|
|
278
|
+
"""AgentTemplate is a frozen dataclass."""
|
|
279
|
+
|
|
280
|
+
def test_frozen(self) -> None:
|
|
281
|
+
t = AgentTemplate(name="test", prompt="hello", annotations={})
|
|
282
|
+
with pytest.raises(AttributeError):
|
|
283
|
+
t.name = "other" # type: ignore[misc]
|
|
284
|
+
|
|
285
|
+
def test_equality(self) -> None:
|
|
286
|
+
a = AgentTemplate(name="x", prompt="p", annotations={"k": "v"})
|
|
287
|
+
b = AgentTemplate(name="x", prompt="p", annotations={"k": "v"})
|
|
288
|
+
assert a == b
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class TestParseMultiDoc:
|
|
292
|
+
"""_parse_multi_doc extracts Agent definitions from multi-document YAML."""
|
|
293
|
+
|
|
294
|
+
def test_parses_agent_definitions(self) -> None:
|
|
295
|
+
from hyperloop.compose import _parse_multi_doc
|
|
296
|
+
|
|
297
|
+
raw = """\
|
|
298
|
+
apiVersion: hyperloop.io/v1
|
|
299
|
+
kind: Agent
|
|
300
|
+
metadata:
|
|
301
|
+
name: implementer
|
|
302
|
+
prompt: |
|
|
303
|
+
You are a worker.
|
|
304
|
+
annotations:
|
|
305
|
+
ambient.io/persona: ""
|
|
306
|
+
---
|
|
307
|
+
apiVersion: hyperloop.io/v1
|
|
308
|
+
kind: Process
|
|
309
|
+
metadata:
|
|
310
|
+
name: default
|
|
311
|
+
pipeline:
|
|
312
|
+
- role: implementer
|
|
313
|
+
---
|
|
314
|
+
apiVersion: hyperloop.io/v1
|
|
315
|
+
kind: Agent
|
|
316
|
+
metadata:
|
|
317
|
+
name: verifier
|
|
318
|
+
prompt: |
|
|
319
|
+
You are a reviewer.
|
|
320
|
+
annotations: {}
|
|
321
|
+
"""
|
|
322
|
+
templates = _parse_multi_doc(raw)
|
|
323
|
+
assert "implementer" in templates
|
|
324
|
+
assert "verifier" in templates
|
|
325
|
+
assert "default" not in templates # Process kind skipped
|
|
326
|
+
|
|
327
|
+
def test_handles_empty_input(self) -> None:
|
|
328
|
+
from hyperloop.compose import _parse_multi_doc
|
|
329
|
+
|
|
330
|
+
templates = _parse_multi_doc("")
|
|
331
|
+
assert templates == {}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class TestKustomizeIntegration:
|
|
335
|
+
"""Integration tests that require kustomize on PATH."""
|
|
336
|
+
|
|
337
|
+
@pytest.mark.skipif(
|
|
338
|
+
not shutil.which("kustomize"),
|
|
339
|
+
reason="kustomize CLI not available",
|
|
340
|
+
)
|
|
341
|
+
def test_from_kustomize_with_local_base(self, tmp_path: Path) -> None:
|
|
342
|
+
"""Build from a local kustomization that references the base dir."""
|
|
343
|
+
import os
|
|
344
|
+
|
|
345
|
+
# kustomize requires relative paths for local resources
|
|
346
|
+
rel_base = os.path.relpath(BASE_DIR, tmp_path)
|
|
347
|
+
kustomization = tmp_path / "kustomization.yaml"
|
|
348
|
+
kustomization.write_text(f"resources:\n - {rel_base}\n")
|
|
349
|
+
|
|
350
|
+
state = _state_with_spec()
|
|
351
|
+
composer = PromptComposer.from_kustomize(str(tmp_path), state)
|
|
352
|
+
|
|
353
|
+
result = composer.compose(
|
|
354
|
+
role="implementer",
|
|
355
|
+
task_id="task-001",
|
|
356
|
+
spec_ref="specs/widget.md",
|
|
357
|
+
findings="",
|
|
358
|
+
)
|
|
359
|
+
assert "You are a worker agent implementing a task" in result
|
|
360
|
+
|
|
361
|
+
@pytest.mark.skipif(
|
|
362
|
+
not shutil.which("kustomize"),
|
|
363
|
+
reason="kustomize CLI not available",
|
|
364
|
+
)
|
|
365
|
+
def test_from_kustomize_no_overlay_fetches_base(self) -> None:
|
|
366
|
+
"""When overlay is None, builds from the hyperloop base remote resource."""
|
|
367
|
+
state = _state_with_spec()
|
|
368
|
+
# This actually hits GitHub — skip in CI if network is unavailable
|
|
369
|
+
try:
|
|
370
|
+
composer = PromptComposer.from_kustomize(None, state)
|
|
371
|
+
except RuntimeError:
|
|
372
|
+
pytest.skip("Network unavailable — cannot fetch remote base")
|
|
373
|
+
|
|
374
|
+
result = composer.compose(
|
|
375
|
+
role="implementer",
|
|
376
|
+
task_id="task-001",
|
|
377
|
+
spec_ref="specs/widget.md",
|
|
378
|
+
findings="",
|
|
379
|
+
)
|
|
380
|
+
assert len(result) > 0
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class TestCheckKustomize:
|
|
384
|
+
"""check_kustomize_available raises SystemExit when kustomize is missing."""
|
|
385
|
+
|
|
386
|
+
def test_raises_when_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
387
|
+
from hyperloop.compose import check_kustomize_available
|
|
388
|
+
|
|
389
|
+
monkeypatch.setattr(shutil, "which", lambda _name: None)
|
|
390
|
+
|
|
391
|
+
with pytest.raises(SystemExit, match="kustomize CLI not found"):
|
|
392
|
+
check_kustomize_available()
|
|
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING
|
|
|
11
11
|
if TYPE_CHECKING:
|
|
12
12
|
from collections.abc import Callable
|
|
13
13
|
|
|
14
|
-
from hyperloop.compose import PromptComposer
|
|
14
|
+
from hyperloop.compose import PromptComposer, load_templates_from_dir
|
|
15
15
|
from hyperloop.domain.model import (
|
|
16
16
|
ActionStep,
|
|
17
17
|
GateStep,
|
|
@@ -999,7 +999,7 @@ class TestPromptComposition:
|
|
|
999
999
|
state.add_task(_task())
|
|
1000
1000
|
state.set_file("specs/task-001.md", "Build a widget.")
|
|
1001
1001
|
|
|
1002
|
-
composer = PromptComposer(
|
|
1002
|
+
composer = PromptComposer(templates=load_templates_from_dir(BASE_DIR), state=state)
|
|
1003
1003
|
orch = _make_orchestrator(state, runtime, composer=composer)
|
|
1004
1004
|
|
|
1005
1005
|
# Cycle 1: spawn implementer
|
|
@@ -1028,7 +1028,7 @@ class TestPromptComposition:
|
|
|
1028
1028
|
state.set_file("specs/task-001.md", "Build a widget.")
|
|
1029
1029
|
state.store_findings("task-001", "Missing null check.\n")
|
|
1030
1030
|
|
|
1031
|
-
composer = PromptComposer(
|
|
1031
|
+
composer = PromptComposer(templates=load_templates_from_dir(BASE_DIR), state=state)
|
|
1032
1032
|
orch = _make_orchestrator(state, runtime, composer=composer)
|
|
1033
1033
|
|
|
1034
1034
|
orch.run_cycle()
|
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
-
from hyperloop.compose import PromptComposer
|
|
12
|
+
from hyperloop.compose import PromptComposer, load_templates_from_dir
|
|
13
13
|
from hyperloop.domain.model import (
|
|
14
14
|
LoopStep,
|
|
15
15
|
Phase,
|
|
@@ -230,7 +230,7 @@ class TestPMIntake:
|
|
|
230
230
|
serial = FakeSerialRunner()
|
|
231
231
|
state.set_file("specs/widget.md", "Widget spec content")
|
|
232
232
|
|
|
233
|
-
composer = PromptComposer(
|
|
233
|
+
composer = PromptComposer(templates=load_templates_from_dir(BASE_DIR), state=state)
|
|
234
234
|
orch = _make_orchestrator(state, runtime, serial_runner=serial, composer=composer)
|
|
235
235
|
|
|
236
236
|
orch.run_cycle()
|
|
@@ -246,7 +246,7 @@ class TestPMIntake:
|
|
|
246
246
|
state.set_file("specs/widget.md", "Widget spec content")
|
|
247
247
|
state.add_task(_task(spec_ref="specs/widget.md"))
|
|
248
248
|
|
|
249
|
-
composer = PromptComposer(
|
|
249
|
+
composer = PromptComposer(templates=load_templates_from_dir(BASE_DIR), state=state)
|
|
250
250
|
orch = _make_orchestrator(state, runtime, serial_runner=serial, composer=composer)
|
|
251
251
|
|
|
252
252
|
orch.run_cycle()
|
|
@@ -288,7 +288,7 @@ class TestPMIntake:
|
|
|
288
288
|
state.set_file("specs/widget.md", "Widget spec")
|
|
289
289
|
state.add_task(_task(spec_ref="specs/widget.md"))
|
|
290
290
|
|
|
291
|
-
composer = PromptComposer(
|
|
291
|
+
composer = PromptComposer(templates=load_templates_from_dir(BASE_DIR), state=state)
|
|
292
292
|
orch = _make_orchestrator(state, runtime, serial_runner=serial, composer=composer)
|
|
293
293
|
|
|
294
294
|
orch.run_cycle()
|
|
@@ -316,7 +316,7 @@ class TestProcessImprover:
|
|
|
316
316
|
state.add_task(_task())
|
|
317
317
|
state.set_file("specs/widget.md", "Widget spec")
|
|
318
318
|
|
|
319
|
-
composer = PromptComposer(
|
|
319
|
+
composer = PromptComposer(templates=load_templates_from_dir(BASE_DIR), state=state)
|
|
320
320
|
orch = _make_orchestrator(
|
|
321
321
|
state, runtime, serial_runner=serial, composer=composer, max_rounds=50
|
|
322
322
|
)
|
|
@@ -345,7 +345,7 @@ class TestProcessImprover:
|
|
|
345
345
|
state.add_task(_task())
|
|
346
346
|
state.set_file("specs/widget.md", "Widget spec")
|
|
347
347
|
|
|
348
|
-
composer = PromptComposer(
|
|
348
|
+
composer = PromptComposer(templates=load_templates_from_dir(BASE_DIR), state=state)
|
|
349
349
|
orch = _make_orchestrator(state, runtime, serial_runner=serial, composer=composer)
|
|
350
350
|
|
|
351
351
|
# Cycle 1: spawn implementer
|
hyperloop-0.4.0/CHANGELOG.md
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
"""Prompt composition — assembles worker prompts from base + overlay + task context.
|
|
2
|
-
|
|
3
|
-
Three layers composed at spawn time:
|
|
4
|
-
1. Base prompt from base/{role}.yaml
|
|
5
|
-
2. Process overlay from specs/prompts/{role}-overlay.yaml (if exists)
|
|
6
|
-
3. Task context: spec content, findings, traceability refs (spec_ref, task_id)
|
|
7
|
-
|
|
8
|
-
For v1, kustomize integration (project overlay) is skipped. The orchestrator
|
|
9
|
-
reads base YAML files directly and injects process overlays + task context.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
from typing import TYPE_CHECKING, cast
|
|
16
|
-
|
|
17
|
-
import yaml
|
|
18
|
-
|
|
19
|
-
if TYPE_CHECKING:
|
|
20
|
-
from hyperloop.ports.state import StateStore
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class PromptComposer:
|
|
24
|
-
"""Composes agent prompts from base definitions + overlays + task context."""
|
|
25
|
-
|
|
26
|
-
def __init__(self, base_dir: str | Path, state: StateStore) -> None:
|
|
27
|
-
"""Load base agent definitions from base_dir.
|
|
28
|
-
|
|
29
|
-
Args:
|
|
30
|
-
base_dir: Path to the directory containing base agent YAML files.
|
|
31
|
-
state: StateStore used to read process overlays and spec files
|
|
32
|
-
from the target repo.
|
|
33
|
-
"""
|
|
34
|
-
self._base_dir = Path(base_dir)
|
|
35
|
-
self._state = state
|
|
36
|
-
self._base_prompts: dict[str, str] = {}
|
|
37
|
-
self._load_base_definitions()
|
|
38
|
-
|
|
39
|
-
def _load_base_definitions(self) -> None:
|
|
40
|
-
"""Load all base agent YAML files and extract their prompt fields."""
|
|
41
|
-
for yaml_file in self._base_dir.glob("*.yaml"):
|
|
42
|
-
with open(yaml_file) as f:
|
|
43
|
-
doc = yaml.safe_load(f)
|
|
44
|
-
if doc and doc.get("kind") == "Agent" and "prompt" in doc:
|
|
45
|
-
name = doc["name"]
|
|
46
|
-
self._base_prompts[name] = doc["prompt"]
|
|
47
|
-
|
|
48
|
-
def compose(
|
|
49
|
-
self,
|
|
50
|
-
role: str,
|
|
51
|
-
task_id: str,
|
|
52
|
-
spec_ref: str,
|
|
53
|
-
findings: str,
|
|
54
|
-
) -> str:
|
|
55
|
-
"""Compose the full prompt for a worker.
|
|
56
|
-
|
|
57
|
-
Layers:
|
|
58
|
-
1. Base prompt from base/{role}.yaml
|
|
59
|
-
2. Process overlay from specs/prompts/{role}-overlay.yaml (if exists)
|
|
60
|
-
3. Task context: spec content, findings, traceability refs
|
|
61
|
-
|
|
62
|
-
Args:
|
|
63
|
-
role: Agent role name (e.g. "implementer", "verifier").
|
|
64
|
-
task_id: Task identifier (e.g. "task-027").
|
|
65
|
-
spec_ref: Path to the originating spec file (e.g. "specs/persistence.md").
|
|
66
|
-
findings: Findings from prior rounds (empty string if none).
|
|
67
|
-
|
|
68
|
-
Returns:
|
|
69
|
-
The composed prompt string ready to pass to a worker.
|
|
70
|
-
|
|
71
|
-
Raises:
|
|
72
|
-
ValueError: If the role has no base agent definition.
|
|
73
|
-
"""
|
|
74
|
-
# Layer 1: Base prompt
|
|
75
|
-
if role not in self._base_prompts:
|
|
76
|
-
msg = f"Unknown role '{role}': no base agent definition found in {self._base_dir}"
|
|
77
|
-
raise ValueError(msg)
|
|
78
|
-
|
|
79
|
-
base_prompt = self._base_prompts[role]
|
|
80
|
-
|
|
81
|
-
# Replace template variables
|
|
82
|
-
prompt = base_prompt.replace("{spec_ref}", spec_ref).replace("{task_id}", task_id)
|
|
83
|
-
|
|
84
|
-
# Layer 2: Process overlay (from target repo specs/prompts/)
|
|
85
|
-
overlay_path = f"specs/prompts/{role}-overlay.yaml"
|
|
86
|
-
overlay_content = self._state.read_file(overlay_path)
|
|
87
|
-
overlay_text = ""
|
|
88
|
-
if overlay_content is not None:
|
|
89
|
-
overlay_text = self._extract_overlay_prompt(overlay_content)
|
|
90
|
-
|
|
91
|
-
# Layer 3: Task context — spec content
|
|
92
|
-
spec_content = self._state.read_file(spec_ref)
|
|
93
|
-
|
|
94
|
-
# Assemble the final prompt
|
|
95
|
-
sections: list[str] = [prompt.rstrip()]
|
|
96
|
-
|
|
97
|
-
if overlay_text:
|
|
98
|
-
sections.append(f"## Process Overlay\n{overlay_text}")
|
|
99
|
-
|
|
100
|
-
if spec_content is not None:
|
|
101
|
-
sections.append(f"## Spec\n{spec_content}")
|
|
102
|
-
else:
|
|
103
|
-
sections.append(
|
|
104
|
-
f"## Spec\n[Spec file '{spec_ref}' not found. Proceed with available context.]"
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
if findings:
|
|
108
|
-
sections.append(f"## Findings\n{findings}")
|
|
109
|
-
|
|
110
|
-
return "\n\n".join(sections) + "\n"
|
|
111
|
-
|
|
112
|
-
@staticmethod
|
|
113
|
-
def _extract_overlay_prompt(raw_yaml: str) -> str:
|
|
114
|
-
"""Extract prompt or content from overlay YAML.
|
|
115
|
-
|
|
116
|
-
Overlay files may contain a 'prompt' field (like agent definitions)
|
|
117
|
-
or raw text content. Handles both.
|
|
118
|
-
"""
|
|
119
|
-
try:
|
|
120
|
-
doc = yaml.safe_load(raw_yaml)
|
|
121
|
-
if isinstance(doc, dict) and "prompt" in doc:
|
|
122
|
-
return str(cast("dict[str, object]", doc)["prompt"]).strip()
|
|
123
|
-
except yaml.YAMLError:
|
|
124
|
-
pass
|
|
125
|
-
# Fall back to raw content
|
|
126
|
-
return raw_yaml.strip()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|