agentforge-framework 0.2.0__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.
- agentforge_framework/.claude-plugin/plugin.json +4 -0
- agentforge_framework/__init__.py +3 -0
- agentforge_framework/agents/__init__.py +92 -0
- agentforge_framework/agents/architect.py +146 -0
- agentforge_framework/agents/implementer.py +162 -0
- agentforge_framework/agents/orchestrator.py +588 -0
- agentforge_framework/agents/reviewer.py +335 -0
- agentforge_framework/agents/security.py +138 -0
- agentforge_framework/agents/tester.py +125 -0
- agentforge_framework/cli.py +461 -0
- agentforge_framework/context/__init__.py +1 -0
- agentforge_framework/context/extractors/__init__.py +76 -0
- agentforge_framework/context/extractors/base.py +47 -0
- agentforge_framework/context/extractors/python.py +65 -0
- agentforge_framework/context/extractors/sql.py +121 -0
- agentforge_framework/context/extractors/yaml.py +59 -0
- agentforge_framework/context/prompt.py +104 -0
- agentforge_framework/context/resolver.py +185 -0
- agentforge_framework/core/__init__.py +1 -0
- agentforge_framework/core/commands.py +170 -0
- agentforge_framework/core/config.py +90 -0
- agentforge_framework/core/contracts.py +875 -0
- agentforge_framework/core/gates.py +333 -0
- agentforge_framework/core/issues.py +697 -0
- agentforge_framework/core/plan_format.py +272 -0
- agentforge_framework/core/process.py +141 -0
- agentforge_framework/core/project.py +262 -0
- agentforge_framework/core/registry.py +455 -0
- agentforge_framework/core/repo.py +185 -0
- agentforge_framework/core/router.py +1 -0
- agentforge_framework/core/runtime.py +639 -0
- agentforge_framework/core/skills.py +255 -0
- agentforge_framework/core/workflow.py +215 -0
- agentforge_framework/plugins/__init__.py +35 -0
- agentforge_framework/plugins/databricks/__init__.py +86 -0
- agentforge_framework/plugins/pyspark/__init__.py +57 -0
- agentforge_framework/plugins/python/__init__.py +45 -0
- agentforge_framework/plugins/sql/__init__.py +377 -0
- agentforge_framework/providers/__init__.py +48 -0
- agentforge_framework/providers/base.py +248 -0
- agentforge_framework/providers/claude.py +159 -0
- agentforge_framework/providers/codex.py +139 -0
- agentforge_framework/skills/MANIFEST.yaml +157 -0
- agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
- agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
- agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
- agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
- agentforge_framework/skills/grilling/SKILL.md +28 -0
- agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
- agentforge_framework/skills/to-spec/SKILL.md +75 -0
- agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
- agentforge_framework/skills/to-tickets/SKILL.md +105 -0
- agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
- agentforge_framework/skills/unslop/SKILL.md +131 -0
- agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
- agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
- agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
- agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
- agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
- agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
- agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
- agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
- agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
- agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
- agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
- agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
- agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
- agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
- agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
- agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
- agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
- agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
- agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
- agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
- agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
- agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
- agentforge_framework/skills/write-plainly/SKILL.md +94 -0
- agentforge_framework/workflows/bugfix.yaml +8 -0
- agentforge_framework/workflows/feature.yaml +16 -0
- agentforge_framework/workflows/review.yaml +10 -0
- agentforge_framework-0.2.0.dist-info/METADATA +321 -0
- agentforge_framework-0.2.0.dist-info/RECORD +89 -0
- agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
- agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
- agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
- agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""The Provider port: one Role, one invocation, one result.
|
|
2
|
+
|
|
3
|
+
ADR-0001 makes every Agent a subprocess call to a coding-agent CLI. `Provider`
|
|
4
|
+
is the whole of that contract — no streaming, no session, no conversation state.
|
|
5
|
+
A Role, a prompt, a Context Pack, a Model Tier, and a working directory go in;
|
|
6
|
+
an `AgentResult` comes out.
|
|
7
|
+
|
|
8
|
+
Two things are deliberately split here:
|
|
9
|
+
|
|
10
|
+
- The **envelope** — how a CLI reports its own success, and where in its output
|
|
11
|
+
the model's text is — belongs to the adapter. A version bump breaks one
|
|
12
|
+
adapter rather than the framework.
|
|
13
|
+
- The **result block** — the delimited JSON a Role is instructed to end with —
|
|
14
|
+
belongs to AgentForge. It travels in the prompt, so every adapter gets it for
|
|
15
|
+
free and none of them has to invent an escalation convention.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import ClassVar
|
|
25
|
+
|
|
26
|
+
from ..core.config import CapabilityTier, Config
|
|
27
|
+
from ..core.contracts import (
|
|
28
|
+
AgentResult,
|
|
29
|
+
ContextPack,
|
|
30
|
+
Finding,
|
|
31
|
+
ModelTier,
|
|
32
|
+
Outcome,
|
|
33
|
+
Role,
|
|
34
|
+
Usage,
|
|
35
|
+
)
|
|
36
|
+
from ..core.plan_format import extract_result_block
|
|
37
|
+
from ..core.process import CommandResult, CommandRunner, MissingBinary, require
|
|
38
|
+
from ..core.skills import expand, read_skill
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ProviderError(RuntimeError):
|
|
42
|
+
"""A Provider could not be used at all, as distinct from an Agent failing."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Provider(ABC):
|
|
46
|
+
"""The port. One method, and it is the only thing a Role knows about."""
|
|
47
|
+
|
|
48
|
+
name: ClassVar[str] = "provider"
|
|
49
|
+
binary: ClassVar[str] = ""
|
|
50
|
+
|
|
51
|
+
#: Whether an Agent may run commands, not merely edit files. Default-deny
|
|
52
|
+
#: per ADR-0007: opened for one Run by an explicit flag, and expressed in
|
|
53
|
+
#: the argument vector rather than in prompt text, because a permission
|
|
54
|
+
#: written as an instruction is one the model can talk itself out of.
|
|
55
|
+
allow_commands: bool = False
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def invoke(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
role: Role,
|
|
62
|
+
prompt: str,
|
|
63
|
+
context: ContextPack,
|
|
64
|
+
tier: ModelTier,
|
|
65
|
+
cwd: Path,
|
|
66
|
+
) -> AgentResult: ...
|
|
67
|
+
|
|
68
|
+
def preflight(self) -> None:
|
|
69
|
+
"""Confirm the CLI exists, naming it if not. Called before a Run starts."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class ProviderOutput:
|
|
74
|
+
"""What an adapter recovered from its CLI's envelope.
|
|
75
|
+
|
|
76
|
+
`usage` is what the CLI said the invocation consumed, and it is recovered
|
|
77
|
+
here rather than centrally because the envelope is the adapter's business:
|
|
78
|
+
ADR-0009 keeps a third Provider from adding a third place that knows how the
|
|
79
|
+
second one reports itself. `None` means the CLI reported nothing, which is
|
|
80
|
+
not the same as a free invocation.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
text: str
|
|
84
|
+
error: str | None = None
|
|
85
|
+
usage: Usage | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CliProvider(Provider):
|
|
89
|
+
"""Shared plumbing for adapters that shell out to a coding-agent CLI.
|
|
90
|
+
|
|
91
|
+
Subclasses supply three small things: the tier-to-model mapping, the
|
|
92
|
+
argument vector, and how to get the model's text out of the CLI's envelope.
|
|
93
|
+
Everything else — turning that text into an `AgentResult` — is the shared
|
|
94
|
+
result-block contract.
|
|
95
|
+
|
|
96
|
+
Extending this is a convenience, not a requirement. An adapter that
|
|
97
|
+
implements `invoke` directly is equally valid, and writing one that way is
|
|
98
|
+
the honest test of whether the port is portable or merely Claude-shaped.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
#: Tier to model identifier. Nothing outside an adapter knows these strings.
|
|
102
|
+
models: ClassVar[dict[ModelTier, str]] = {}
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
runner: CommandRunner,
|
|
107
|
+
timeout: float | None = 1800.0,
|
|
108
|
+
allow_commands: bool = False,
|
|
109
|
+
config: Config | None = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
self.runner = runner
|
|
112
|
+
self.timeout = timeout
|
|
113
|
+
self.allow_commands = allow_commands
|
|
114
|
+
self.capability_tier = (config or Config()).capability_for(self.name)
|
|
115
|
+
|
|
116
|
+
def preflight(self) -> None:
|
|
117
|
+
try:
|
|
118
|
+
require(
|
|
119
|
+
self.runner,
|
|
120
|
+
self.binary,
|
|
121
|
+
f"AgentForge drives the {self.name} CLI (ADR-0001); install it or "
|
|
122
|
+
f"select another provider with --provider.",
|
|
123
|
+
)
|
|
124
|
+
except MissingBinary as exc:
|
|
125
|
+
raise ProviderError(str(exc)) from exc
|
|
126
|
+
|
|
127
|
+
def model_for(self, tier: ModelTier) -> str:
|
|
128
|
+
try:
|
|
129
|
+
return self.models[tier]
|
|
130
|
+
except KeyError as exc:
|
|
131
|
+
raise ProviderError(
|
|
132
|
+
f"the {self.name} adapter has no model for tier {tier!r}"
|
|
133
|
+
) from exc
|
|
134
|
+
|
|
135
|
+
@abstractmethod
|
|
136
|
+
def build_argv(
|
|
137
|
+
self, prompt: str, model: str, native_skills: tuple[str, ...] = ()
|
|
138
|
+
) -> Sequence[str]: ...
|
|
139
|
+
|
|
140
|
+
@abstractmethod
|
|
141
|
+
def parse_output(self, result: CommandResult) -> ProviderOutput: ...
|
|
142
|
+
|
|
143
|
+
def invoke(
|
|
144
|
+
self,
|
|
145
|
+
*,
|
|
146
|
+
role: Role,
|
|
147
|
+
prompt: str,
|
|
148
|
+
context: ContextPack,
|
|
149
|
+
tier: ModelTier,
|
|
150
|
+
cwd: Path,
|
|
151
|
+
) -> AgentResult:
|
|
152
|
+
prompt, native_skills = self._deliver_skills(role, prompt)
|
|
153
|
+
argv = self.build_argv(prompt, self.model_for(tier), native_skills)
|
|
154
|
+
completed = self.runner.run(argv, cwd=cwd, timeout=self.timeout)
|
|
155
|
+
output = self.parse_output(completed)
|
|
156
|
+
return to_agent_result(role=role, tier=tier, output=output)
|
|
157
|
+
|
|
158
|
+
def _deliver_skills(self, role: Role, prompt: str) -> tuple[str, tuple[str, ...]]:
|
|
159
|
+
"""Validate and deliver the Role's skills before the CLI is invoked.
|
|
160
|
+
|
|
161
|
+
A native Provider is named the skills the Role declared and no more: a
|
|
162
|
+
composite fans out to its parts through the CLI's own Skill mechanism,
|
|
163
|
+
which is what the mechanism is for.
|
|
164
|
+
|
|
165
|
+
A Fragment Provider has no such mechanism, so the composite is expanded
|
|
166
|
+
here and every body travels. Without that, a skill whose text says "run
|
|
167
|
+
these two" reaches a Provider that cannot run anything and the Role is
|
|
168
|
+
left with an instruction pointing at nothing.
|
|
169
|
+
"""
|
|
170
|
+
if not role.skills:
|
|
171
|
+
return prompt, ()
|
|
172
|
+
|
|
173
|
+
if self.capability_tier is CapabilityTier.NATIVE:
|
|
174
|
+
for name in role.skills:
|
|
175
|
+
read_skill(name) # refuse a name nothing answers for, before the CLI runs
|
|
176
|
+
commands = ", ".join(f"/agentforge:{name}" for name in role.skills)
|
|
177
|
+
instruction = (
|
|
178
|
+
f"Use the declared native AgentForge skills before doing this work: {commands}."
|
|
179
|
+
)
|
|
180
|
+
return f"{instruction}\n\n{prompt}", role.skills
|
|
181
|
+
|
|
182
|
+
fragments = [
|
|
183
|
+
f"## Skill: {name}\n\n{read_skill(name).rstrip()}" for name in expand(role.skills)
|
|
184
|
+
]
|
|
185
|
+
return f"{prompt}\n\n" + "\n\n".join(fragments) + "\n", ()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def to_agent_result(*, role: Role, tier: ModelTier, output: ProviderOutput) -> AgentResult:
|
|
189
|
+
"""Turn a Role's text output into a result the runtime can act on.
|
|
190
|
+
|
|
191
|
+
A Role that reports nothing parseable is a failure, not a quiet success. The
|
|
192
|
+
alternative — treating unstructured text as a completed step — is how a Run
|
|
193
|
+
opens a pull request containing no changes and says it worked.
|
|
194
|
+
|
|
195
|
+
Every way out of here carries the usage the adapter recovered, failures
|
|
196
|
+
included. A Run that spent four dollars discovering its CLI was misconfigured
|
|
197
|
+
spent four dollars, and a total that quietly omitted them would be the one
|
|
198
|
+
figure a reader most wants.
|
|
199
|
+
"""
|
|
200
|
+
if output.error:
|
|
201
|
+
return AgentResult(
|
|
202
|
+
role=role.name,
|
|
203
|
+
tier=tier,
|
|
204
|
+
outcome=Outcome.FAILED,
|
|
205
|
+
summary=output.error,
|
|
206
|
+
detail=output.text,
|
|
207
|
+
raw=output.text,
|
|
208
|
+
usage=output.usage,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
payload = extract_result_block(output.text)
|
|
212
|
+
if payload is None:
|
|
213
|
+
return AgentResult(
|
|
214
|
+
role=role.name,
|
|
215
|
+
tier=tier,
|
|
216
|
+
outcome=Outcome.FAILED,
|
|
217
|
+
summary=(
|
|
218
|
+
f"the {role.name} Agent finished without reporting a result block, "
|
|
219
|
+
"so AgentForge cannot tell what it did"
|
|
220
|
+
),
|
|
221
|
+
detail=output.text,
|
|
222
|
+
raw=output.text,
|
|
223
|
+
usage=output.usage,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
outcome = Outcome(str(payload.get("outcome", "")).strip().lower())
|
|
228
|
+
except ValueError:
|
|
229
|
+
outcome = Outcome.FAILED
|
|
230
|
+
|
|
231
|
+
summary = str(payload.get("summary") or "").strip()
|
|
232
|
+
if outcome is Outcome.FAILED and not summary:
|
|
233
|
+
summary = f"the {role.name} Agent reported an unrecognized outcome"
|
|
234
|
+
|
|
235
|
+
return AgentResult(
|
|
236
|
+
role=role.name,
|
|
237
|
+
tier=tier,
|
|
238
|
+
outcome=outcome,
|
|
239
|
+
summary=summary,
|
|
240
|
+
detail=str(payload.get("detail") or ""),
|
|
241
|
+
files_changed=tuple(payload.get("files_changed") or ()),
|
|
242
|
+
findings=tuple(Finding.coerce(item) for item in payload.get("findings") or ()),
|
|
243
|
+
raw=output.text,
|
|
244
|
+
usage=output.usage,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
__all__ = ["CliProvider", "Provider", "ProviderError", "ProviderOutput", "to_agent_result"]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""The Claude Code adapter.
|
|
2
|
+
|
|
3
|
+
`claude -p` runs headlessly: it takes a prompt, edits files in the working
|
|
4
|
+
directory, and exits. `--output-format json` wraps the run in an envelope
|
|
5
|
+
carrying an `is_error` flag and the model's final text under `result`, which is
|
|
6
|
+
the only reason this adapter can tell a CLI failure from an Agent failure.
|
|
7
|
+
|
|
8
|
+
Model identifiers appear here and nowhere else in AgentForge (ADR-0004). The
|
|
9
|
+
aliases are used rather than pinned versions so that a vendor release does not
|
|
10
|
+
require an AgentForge release; a team that wants a specific version overrides
|
|
11
|
+
the mapping in configuration once `agentforge init` exists (M5).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from typing import ClassVar
|
|
19
|
+
|
|
20
|
+
from ..core.contracts import ModelTier, Usage
|
|
21
|
+
from ..core.process import CommandResult
|
|
22
|
+
from ..core.skills import SKILLS_ROOT
|
|
23
|
+
from .base import CliProvider, ProviderOutput
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ClaudeProvider(CliProvider):
|
|
27
|
+
name: ClassVar[str] = "claude"
|
|
28
|
+
binary: ClassVar[str] = "claude"
|
|
29
|
+
|
|
30
|
+
models: ClassVar[dict[ModelTier, str]] = {
|
|
31
|
+
ModelTier.DEEP: "opus",
|
|
32
|
+
ModelTier.STANDARD: "sonnet",
|
|
33
|
+
ModelTier.CHEAP: "haiku",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#: ADR-0007's two postures, mapped onto this CLI's permission modes.
|
|
37
|
+
#: `acceptEdits` lets an Agent write files and nothing else; commands still
|
|
38
|
+
#: need a confirmation that headless mode cannot give, which is the whole
|
|
39
|
+
#: bug in #18. `bypassPermissions` is the open gate. An Agent that has to
|
|
40
|
+
#: ask before every edit cannot run unattended at all, so there is no
|
|
41
|
+
#: third, stricter posture — the blast radius is bounded by the branch the
|
|
42
|
+
#: runtime creates before any Agent is invoked.
|
|
43
|
+
DENIED: ClassVar[str] = "acceptEdits"
|
|
44
|
+
PERMITTED: ClassVar[str] = "bypassPermissions"
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def permission_mode(self) -> str:
|
|
48
|
+
return self.PERMITTED if self.allow_commands else self.DENIED
|
|
49
|
+
|
|
50
|
+
def build_argv(
|
|
51
|
+
self, prompt: str, model: str, native_skills: tuple[str, ...] = ()
|
|
52
|
+
) -> Sequence[str]:
|
|
53
|
+
argv = (
|
|
54
|
+
self.binary,
|
|
55
|
+
"-p",
|
|
56
|
+
prompt,
|
|
57
|
+
"--model",
|
|
58
|
+
model,
|
|
59
|
+
"--output-format",
|
|
60
|
+
"json",
|
|
61
|
+
"--permission-mode",
|
|
62
|
+
self.permission_mode,
|
|
63
|
+
)
|
|
64
|
+
if native_skills:
|
|
65
|
+
argv += ("--plugin-dir", str(SKILLS_ROOT.parent))
|
|
66
|
+
return argv
|
|
67
|
+
|
|
68
|
+
def parse_output(self, result: CommandResult) -> ProviderOutput:
|
|
69
|
+
"""Unwrap the JSON envelope.
|
|
70
|
+
|
|
71
|
+
Failure modes, in the order they actually happen: the CLI is missing or
|
|
72
|
+
crashed before printing anything; it printed something that is not JSON;
|
|
73
|
+
it printed a well-formed envelope reporting its own error.
|
|
74
|
+
"""
|
|
75
|
+
stdout = result.stdout.strip()
|
|
76
|
+
if not stdout:
|
|
77
|
+
detail = result.stderr.strip() or f"exit status {result.returncode}"
|
|
78
|
+
return ProviderOutput(text="", error=f"the claude CLI produced no output: {detail}")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
envelope = json.loads(stdout)
|
|
82
|
+
except json.JSONDecodeError:
|
|
83
|
+
# Older CLIs and `--output-format text` print bare text. The result
|
|
84
|
+
# block still travels in it, so this degrades rather than fails.
|
|
85
|
+
if result.ok:
|
|
86
|
+
return ProviderOutput(text=stdout)
|
|
87
|
+
return ProviderOutput(
|
|
88
|
+
text=stdout,
|
|
89
|
+
error=f"the claude CLI exited {result.returncode} without a JSON envelope",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
record = _final_record(envelope)
|
|
93
|
+
text = str(record.get("result") or record.get("text") or "")
|
|
94
|
+
usage = _usage(record)
|
|
95
|
+
|
|
96
|
+
if record.get("is_error") or not result.ok:
|
|
97
|
+
reason = text.strip() or result.stderr.strip() or f"exit status {result.returncode}"
|
|
98
|
+
return ProviderOutput(
|
|
99
|
+
text=text,
|
|
100
|
+
error=f"the claude CLI reported an error: {reason}",
|
|
101
|
+
usage=usage,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return ProviderOutput(text=text, usage=usage)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
#: What the envelope calls the tokens that went in. Cached ones are counted
|
|
108
|
+
#: with the rest: they are cheaper, not free, and the price the CLI charged for
|
|
109
|
+
#: them is already inside `total_cost_usd` — a token figure that omitted them
|
|
110
|
+
#: would disagree with the dollar figure beside it.
|
|
111
|
+
_INPUT_FIELDS = ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _usage(record: dict) -> Usage | None:
|
|
115
|
+
"""What this envelope says the invocation consumed.
|
|
116
|
+
|
|
117
|
+
This CLI is the generous one: dollars and a token split, both in the record
|
|
118
|
+
AgentForge was already parsing for the result text. `None` when the envelope
|
|
119
|
+
carries neither, so that a Run Log line can say the CLI reported nothing
|
|
120
|
+
rather than printing a zero nobody measured.
|
|
121
|
+
"""
|
|
122
|
+
counts = record.get("usage")
|
|
123
|
+
counts = counts if isinstance(counts, dict) else {}
|
|
124
|
+
|
|
125
|
+
inputs = [_number(counts.get(field), int) for field in _INPUT_FIELDS]
|
|
126
|
+
counted = [value for value in inputs if value is not None]
|
|
127
|
+
|
|
128
|
+
usage = Usage(
|
|
129
|
+
provider=ClaudeProvider.name,
|
|
130
|
+
input_tokens=sum(counted) if counted else None,
|
|
131
|
+
output_tokens=_number(counts.get("output_tokens"), int),
|
|
132
|
+
cost_usd=_number(record.get("total_cost_usd"), float),
|
|
133
|
+
)
|
|
134
|
+
return usage if usage.reported else None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _number(value: object, cast):
|
|
138
|
+
"""One figure out of the envelope, or `None` where it had none to give."""
|
|
139
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
140
|
+
return None
|
|
141
|
+
return cast(value)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _final_record(envelope: object) -> dict:
|
|
146
|
+
"""The terminal record, whether the CLI emitted one object or a stream."""
|
|
147
|
+
if isinstance(envelope, dict):
|
|
148
|
+
return envelope
|
|
149
|
+
if isinstance(envelope, list):
|
|
150
|
+
for item in reversed(envelope):
|
|
151
|
+
if isinstance(item, dict) and item.get("type") == "result":
|
|
152
|
+
return item
|
|
153
|
+
for item in reversed(envelope):
|
|
154
|
+
if isinstance(item, dict):
|
|
155
|
+
return item
|
|
156
|
+
return {}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
__all__ = ["ClaudeProvider"]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""A second adapter, to keep ADR-0001's portability claim honest.
|
|
2
|
+
|
|
3
|
+
This exists to prove the Provider port is not merely Claude-shaped. `codex exec`
|
|
4
|
+
differs from `claude -p` in the two places that matter: it prints a transcript
|
|
5
|
+
rather than a JSON envelope, so success has to be read from the exit status, and
|
|
6
|
+
its model identifiers are its own.
|
|
7
|
+
|
|
8
|
+
The posture flags and model slugs below are read off a real install — `codex
|
|
9
|
+
--help` and `~/.codex/models_cache.json` — rather than guessed. The previous
|
|
10
|
+
`gpt-5-codex*` identifiers existed on no account we could find, which is
|
|
11
|
+
ADR-0004's whole point: tier names outlive model names, and a pinned
|
|
12
|
+
identifier goes stale inside a release.
|
|
13
|
+
|
|
14
|
+
This CLI carries reasoning effort separately from model choice, and the
|
|
15
|
+
per-model defaults disagree: `gpt-5.6-sol` starts at `low` while the rest
|
|
16
|
+
start at `medium`. Left alone, `deep` would buy the frontier model and ask it
|
|
17
|
+
to think as little as possible. The adapter pins one value across all three
|
|
18
|
+
tiers instead, so the Model Tier chooses the model and nothing else shifts
|
|
19
|
+
underneath it.
|
|
20
|
+
|
|
21
|
+
Per the note on Issue #1: the useful version of this file is one written by
|
|
22
|
+
somebody who has not read `claude.py`. This one was not, so treat its shape as a
|
|
23
|
+
weaker signal than a genuinely independent adapter would be.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from collections.abc import Sequence
|
|
30
|
+
from typing import ClassVar
|
|
31
|
+
|
|
32
|
+
from ..core.contracts import ModelTier, Usage
|
|
33
|
+
from ..core.process import CommandResult
|
|
34
|
+
from .base import CliProvider, ProviderOutput
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CodexProvider(CliProvider):
|
|
38
|
+
name: ClassVar[str] = "codex"
|
|
39
|
+
binary: ClassVar[str] = "codex"
|
|
40
|
+
|
|
41
|
+
#: Slugs read out of a real install's `~/.codex/models_cache.json` rather
|
|
42
|
+
#: than guessed. The tiers step back a generation at a time: the current
|
|
43
|
+
#: frontier model for `deep`, then the two preceding releases. The
|
|
44
|
+
#: same-generation alternatives (`gpt-5.6-terra`, `gpt-5.6-luna`) are the
|
|
45
|
+
#: obvious other reading of ADR-0004's three tiers; this mapping is the
|
|
46
|
+
#: maintainer's choice, and overriding it is a configuration line.
|
|
47
|
+
models: ClassVar[dict[ModelTier, str]] = {
|
|
48
|
+
ModelTier.DEEP: "gpt-5.6-sol",
|
|
49
|
+
ModelTier.STANDARD: "gpt-5.5",
|
|
50
|
+
ModelTier.CHEAP: "gpt-5.4",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#: ADR-0007's two postures, on the two axes `codex --help` documents.
|
|
54
|
+
#:
|
|
55
|
+
#: The sandbox never changes: an Agent writes in the workspace and nowhere
|
|
56
|
+
#: else, in both postures. What the gate moves is the approval policy.
|
|
57
|
+
#: `untrusted` auto-runs only reads (`ls`, `cat`, `sed`) and escalates
|
|
58
|
+
#: anything else, which is this CLI's nearest analogue to the `claude`
|
|
59
|
+
#: adapter's `acceptEdits`. `never` stops asking.
|
|
60
|
+
#:
|
|
61
|
+
#: `danger-full-access` and `--dangerously-bypass-approvals-and-sandbox`
|
|
62
|
+
#: are deliberately unused. ADR-0007 opens a gate; it does not remove the
|
|
63
|
+
#: sandbox, and an unattended Role is the last thing that should be outside
|
|
64
|
+
#: one.
|
|
65
|
+
SANDBOX: ClassVar[str] = "workspace-write"
|
|
66
|
+
DENIED: ClassVar[str] = "untrusted"
|
|
67
|
+
PERMITTED: ClassVar[str] = "never"
|
|
68
|
+
|
|
69
|
+
#: Pinned across every tier, because the per-model defaults disagree —
|
|
70
|
+
#: `gpt-5.6-sol` starts at `low`, the others at `medium`. Setting it here
|
|
71
|
+
#: keeps a Model Tier meaning one thing: it picks the model, and the
|
|
72
|
+
#: reasoning depth stays where the maintainer put it. `low` through `ultra`
|
|
73
|
+
#: are available; raising it is a configuration change under ADR-0004
|
|
74
|
+
#: rather than an edit here.
|
|
75
|
+
REASONING_EFFORT: ClassVar[str] = "medium"
|
|
76
|
+
|
|
77
|
+
def build_argv(
|
|
78
|
+
self, prompt: str, model: str, native_skills: tuple[str, ...] = ()
|
|
79
|
+
) -> Sequence[str]:
|
|
80
|
+
"""Options precede the subcommand: `codex [OPTIONS] <COMMAND> [ARGS]`.
|
|
81
|
+
|
|
82
|
+
This adapter previously passed `--full-auto` after `exec`, which fails
|
|
83
|
+
twice over: that flag does not exist in the current CLI, and options
|
|
84
|
+
placed after the subcommand are rejected regardless.
|
|
85
|
+
|
|
86
|
+
Reasoning effort is pinned rather than derived from the tier, which is
|
|
87
|
+
what lets it be set here at all: a per-tier value would need the Model
|
|
88
|
+
Tier, and the port hands that to `model_for` and not to this method.
|
|
89
|
+
"""
|
|
90
|
+
return (
|
|
91
|
+
self.binary,
|
|
92
|
+
"--model",
|
|
93
|
+
model,
|
|
94
|
+
"-c",
|
|
95
|
+
f"model_reasoning_effort={self.REASONING_EFFORT}",
|
|
96
|
+
"--sandbox",
|
|
97
|
+
self.SANDBOX,
|
|
98
|
+
"--ask-for-approval",
|
|
99
|
+
self.PERMITTED if self.allow_commands else self.DENIED,
|
|
100
|
+
"exec",
|
|
101
|
+
prompt,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def parse_output(self, result: CommandResult) -> ProviderOutput:
|
|
105
|
+
"""No envelope. The transcript is the output and the exit code is the verdict."""
|
|
106
|
+
usage = _usage(result.stdout)
|
|
107
|
+
|
|
108
|
+
if result.ok:
|
|
109
|
+
return ProviderOutput(text=result.stdout, usage=usage)
|
|
110
|
+
|
|
111
|
+
detail = (result.stderr or result.stdout or "").strip()
|
|
112
|
+
return ProviderOutput(
|
|
113
|
+
text=result.stdout,
|
|
114
|
+
error=f"the codex CLI exited {result.returncode}: {detail[:400]}",
|
|
115
|
+
usage=usage,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
#: The last line of a transcript, on a run that got that far. One figure and no
|
|
120
|
+
#: split, which is the asymmetry ADR-0009 exists to keep honest: this adapter
|
|
121
|
+
#: reports tokens and never dollars, and the Run Log says so rather than leaving
|
|
122
|
+
#: a blank where a price would have been.
|
|
123
|
+
_TOKENS = re.compile(r"tokens used:\s*([\d,]+)", re.IGNORECASE)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _usage(transcript: str) -> Usage | None:
|
|
127
|
+
"""The token count this CLI prints when it finishes, if it printed one.
|
|
128
|
+
|
|
129
|
+
The last match rather than the first: a transcript that reports per-turn
|
|
130
|
+
counts ends with the one for the whole invocation, and an Agent that quoted
|
|
131
|
+
the phrase in its own output would otherwise be believed over the CLI.
|
|
132
|
+
"""
|
|
133
|
+
matches = _TOKENS.findall(transcript or "")
|
|
134
|
+
if not matches:
|
|
135
|
+
return None
|
|
136
|
+
return Usage(provider=CodexProvider.name, total_tokens=int(matches[-1].replace(",", "")))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
__all__ = ["CodexProvider"]
|