aofire-python-agent 0.1.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.
- aofire_python_agent-0.1.0.dist-info/METADATA +405 -0
- aofire_python_agent-0.1.0.dist-info/RECORD +23 -0
- aofire_python_agent-0.1.0.dist-info/WHEEL +5 -0
- aofire_python_agent-0.1.0.dist-info/entry_points.txt +7 -0
- aofire_python_agent-0.1.0.dist-info/licenses/LICENSE +28 -0
- aofire_python_agent-0.1.0.dist-info/top_level.txt +1 -0
- python_agent/CLAUDE.md +105 -0
- python_agent/__init__.py +3 -0
- python_agent/agent_utils.py +61 -0
- python_agent/call_graph.py +694 -0
- python_agent/coding_agent.py +193 -0
- python_agent/convergence_agent.py +362 -0
- python_agent/dag_integrity.py +198 -0
- python_agent/dag_utils.py +181 -0
- python_agent/discovery_agent.py +348 -0
- python_agent/divergence_agent.py +302 -0
- python_agent/ontology.py +270 -0
- python_agent/planning_agent.py +83 -0
- python_agent/py.typed +0 -0
- python_agent/rules.py +383 -0
- python_agent/tool_guard.py +164 -0
- python_agent/tools/__init__.py +0 -0
- python_agent/types.py +38 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Discovery agent: interactive ontology building."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from claude_agent_sdk import (
|
|
11
|
+
AssistantMessage,
|
|
12
|
+
ClaudeAgentOptions,
|
|
13
|
+
ClaudeSDKClient,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from python_agent.agent_utils import (
|
|
17
|
+
collect_response_text,
|
|
18
|
+
extract_ontology_json,
|
|
19
|
+
print_text_blocks,
|
|
20
|
+
read_user_input,
|
|
21
|
+
)
|
|
22
|
+
from python_agent.dag_utils import (
|
|
23
|
+
load_dag,
|
|
24
|
+
save_dag,
|
|
25
|
+
save_snapshot,
|
|
26
|
+
)
|
|
27
|
+
from python_agent.ontology import (
|
|
28
|
+
DAGNode,
|
|
29
|
+
DomainConstraint,
|
|
30
|
+
Entity,
|
|
31
|
+
Ontology,
|
|
32
|
+
OntologyDAG,
|
|
33
|
+
OpenQuestion,
|
|
34
|
+
Relationship,
|
|
35
|
+
)
|
|
36
|
+
from python_agent.rules import (
|
|
37
|
+
discovery_system_prompt,
|
|
38
|
+
frame_data,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _upsert_entities(
|
|
43
|
+
ontology: Ontology, new_entities: list[Any],
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Replace or append entities by id."""
|
|
46
|
+
existing = {
|
|
47
|
+
e.id: i for i, e in enumerate(ontology.entities)
|
|
48
|
+
}
|
|
49
|
+
for item in new_entities:
|
|
50
|
+
entity = Entity.model_validate(item)
|
|
51
|
+
idx = existing.get(entity.id)
|
|
52
|
+
if idx is not None:
|
|
53
|
+
ontology.entities[idx] = entity
|
|
54
|
+
else:
|
|
55
|
+
ontology.entities.append(entity)
|
|
56
|
+
existing[entity.id] = len(
|
|
57
|
+
ontology.entities,
|
|
58
|
+
) - 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _append_relationships(
|
|
62
|
+
ontology: Ontology, items: list[Any],
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Append new relationships to the ontology."""
|
|
65
|
+
for item in items:
|
|
66
|
+
ontology.relationships.append(
|
|
67
|
+
Relationship.model_validate(item),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _append_constraints(
|
|
72
|
+
ontology: Ontology, items: list[Any],
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Append new domain constraints to the ontology."""
|
|
75
|
+
for item in items:
|
|
76
|
+
ontology.domain_constraints.append(
|
|
77
|
+
DomainConstraint.model_validate(item),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _upsert_open_questions(
|
|
82
|
+
ontology: Ontology, items: list[Any],
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Replace or append open questions by id."""
|
|
85
|
+
existing = {
|
|
86
|
+
q.id: i
|
|
87
|
+
for i, q in enumerate(ontology.open_questions)
|
|
88
|
+
}
|
|
89
|
+
for item in items:
|
|
90
|
+
question = OpenQuestion.model_validate(item)
|
|
91
|
+
idx = existing.get(question.id)
|
|
92
|
+
if idx is not None:
|
|
93
|
+
ontology.open_questions[idx] = question
|
|
94
|
+
else:
|
|
95
|
+
ontology.open_questions.append(question)
|
|
96
|
+
existing[question.id] = len(
|
|
97
|
+
ontology.open_questions,
|
|
98
|
+
) - 1
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_MERGE_DISPATCH = {
|
|
102
|
+
"entities": _upsert_entities,
|
|
103
|
+
"relationships": _append_relationships,
|
|
104
|
+
"domain_constraints": _append_constraints,
|
|
105
|
+
"open_questions": _upsert_open_questions,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def merge_ontology_update(
|
|
110
|
+
ontology: Ontology, update_dict: dict[str, Any],
|
|
111
|
+
) -> bool:
|
|
112
|
+
"""Apply a partial ontology update to an Ontology.
|
|
113
|
+
|
|
114
|
+
Returns True if any updates were applied.
|
|
115
|
+
"""
|
|
116
|
+
applied = False
|
|
117
|
+
for key, handler in _MERGE_DISPATCH.items():
|
|
118
|
+
items = update_dict.get(key)
|
|
119
|
+
if items:
|
|
120
|
+
handler(ontology, items)
|
|
121
|
+
applied = True
|
|
122
|
+
return applied
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def process_response(
|
|
126
|
+
response_text: str, ontology: Ontology,
|
|
127
|
+
) -> bool:
|
|
128
|
+
"""Extract and apply ontology updates from response text.
|
|
129
|
+
|
|
130
|
+
Returns True if an update was applied.
|
|
131
|
+
"""
|
|
132
|
+
update = extract_ontology_json(response_text)
|
|
133
|
+
if update is None:
|
|
134
|
+
return False
|
|
135
|
+
return merge_ontology_update(ontology, update)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _format_entities(ontology: Ontology) -> list[str]:
|
|
139
|
+
"""Format entity lines for summary."""
|
|
140
|
+
lines = [f"Entities ({len(ontology.entities)}):"]
|
|
141
|
+
for e in ontology.entities:
|
|
142
|
+
props = ", ".join(p.name for p in e.properties)
|
|
143
|
+
lines.append(f" {e.id}: {e.name} [{props}]")
|
|
144
|
+
return lines
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _format_relationships(
|
|
148
|
+
ontology: Ontology,
|
|
149
|
+
) -> list[str]:
|
|
150
|
+
"""Format relationship lines for summary."""
|
|
151
|
+
lines = [
|
|
152
|
+
f"Relationships ({len(ontology.relationships)}):",
|
|
153
|
+
]
|
|
154
|
+
for r in ontology.relationships:
|
|
155
|
+
lines.append(
|
|
156
|
+
f" {r.source_entity_id} --{r.name}--> "
|
|
157
|
+
f"{r.target_entity_id} ({r.cardinality})",
|
|
158
|
+
)
|
|
159
|
+
return lines
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _format_questions(ontology: Ontology) -> list[str]:
|
|
163
|
+
"""Format open question lines for summary."""
|
|
164
|
+
lines = [
|
|
165
|
+
f"Open Questions ({len(ontology.open_questions)}):",
|
|
166
|
+
]
|
|
167
|
+
for q in ontology.open_questions:
|
|
168
|
+
status = "RESOLVED" if q.resolved else "open"
|
|
169
|
+
lines.append(f" [{status}] {q.id}: {q.text}")
|
|
170
|
+
return lines
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def format_ontology_summary(ontology: Ontology) -> str:
|
|
174
|
+
"""Format a human-readable summary of the ontology."""
|
|
175
|
+
lines = _format_entities(ontology)
|
|
176
|
+
lines += _format_relationships(ontology)
|
|
177
|
+
n = len(ontology.domain_constraints)
|
|
178
|
+
lines.append(f"Constraints ({n}):")
|
|
179
|
+
for c in ontology.domain_constraints:
|
|
180
|
+
lines.append(f" {c.name}: {c.description}")
|
|
181
|
+
lines += _format_questions(ontology)
|
|
182
|
+
return "\n".join(lines)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def backtrack(dag: OntologyDAG) -> DAGNode | None:
|
|
186
|
+
"""Move to the parent of the current node.
|
|
187
|
+
|
|
188
|
+
Returns the new current node, or None if at root.
|
|
189
|
+
"""
|
|
190
|
+
parents = dag.parents_of(dag.current_node_id)
|
|
191
|
+
if not parents:
|
|
192
|
+
return None
|
|
193
|
+
dag.current_node_id = parents[0].id
|
|
194
|
+
return dag.get_current_node()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
_CmdResult = tuple[str, Ontology | None]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _handle_show(ontology: Ontology) -> _CmdResult:
|
|
201
|
+
"""Handle the 'show' command."""
|
|
202
|
+
return format_ontology_summary(ontology), None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _handle_save(
|
|
206
|
+
command: str, ontology: Ontology,
|
|
207
|
+
dag: OntologyDAG, dag_path: str,
|
|
208
|
+
) -> _CmdResult:
|
|
209
|
+
"""Handle the 'save' command."""
|
|
210
|
+
label = command.strip()[4:].strip() or "snapshot"
|
|
211
|
+
save_snapshot(dag, ontology, label)
|
|
212
|
+
save_dag(dag, dag_path)
|
|
213
|
+
return f"Saved snapshot: {dag.current_node_id}", None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _handle_back(
|
|
217
|
+
dag: OntologyDAG, dag_path: str,
|
|
218
|
+
) -> _CmdResult:
|
|
219
|
+
"""Handle the 'back' command."""
|
|
220
|
+
node = backtrack(dag)
|
|
221
|
+
if node is None:
|
|
222
|
+
return "Already at root.", None
|
|
223
|
+
new_onto = node.ontology.model_copy(deep=True)
|
|
224
|
+
save_dag(dag, dag_path)
|
|
225
|
+
return (
|
|
226
|
+
f"Backtracked to: {node.id} ({node.label})",
|
|
227
|
+
new_onto,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def is_command(text: str) -> bool:
|
|
232
|
+
"""Check if user input is a meta-command."""
|
|
233
|
+
cmd = text.strip().lower()
|
|
234
|
+
return cmd in ("show", "back") or cmd.startswith(
|
|
235
|
+
"save",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def handle_command(
|
|
240
|
+
command: str, ontology: Ontology,
|
|
241
|
+
dag: OntologyDAG, dag_path: str,
|
|
242
|
+
) -> _CmdResult:
|
|
243
|
+
"""Dispatch a user meta-command.
|
|
244
|
+
|
|
245
|
+
Returns (message, new_ontology_or_None).
|
|
246
|
+
"""
|
|
247
|
+
cmd = command.strip().lower()
|
|
248
|
+
if cmd == "show":
|
|
249
|
+
return _handle_show(ontology)
|
|
250
|
+
if cmd == "back":
|
|
251
|
+
return _handle_back(dag, dag_path)
|
|
252
|
+
return _handle_save(command, ontology, dag, dag_path)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def print_response(client: Any) -> str:
|
|
256
|
+
"""Receive and print response. Return full text."""
|
|
257
|
+
parts: list[str] = []
|
|
258
|
+
async for message in client.receive_response():
|
|
259
|
+
if isinstance(message, AssistantMessage):
|
|
260
|
+
print_text_blocks(message)
|
|
261
|
+
parts.append(collect_response_text(message))
|
|
262
|
+
return "\n".join(parts)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _init_ontology(dag: OntologyDAG) -> Ontology:
|
|
266
|
+
"""Get ontology from current DAG node, or a new one."""
|
|
267
|
+
node = dag.get_current_node()
|
|
268
|
+
if node is None:
|
|
269
|
+
return Ontology()
|
|
270
|
+
return node.ontology.model_copy(deep=True)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# taint: ignore[CWE-200] -- Interactive agent displays LLM output to user
|
|
274
|
+
async def run(
|
|
275
|
+
description: str, model: str, dag_path: str,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Run the discovery agent interactively."""
|
|
278
|
+
prompt = discovery_system_prompt()
|
|
279
|
+
dag = load_dag(dag_path, description)
|
|
280
|
+
ontology = _init_ontology(dag)
|
|
281
|
+
|
|
282
|
+
options = ClaudeAgentOptions(
|
|
283
|
+
model=model,
|
|
284
|
+
system_prompt=prompt,
|
|
285
|
+
allowed_tools=[],
|
|
286
|
+
permission_mode="default",
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
async with ClaudeSDKClient(options=options) as client:
|
|
290
|
+
framed = frame_data("user-input", description)
|
|
291
|
+
await client.query(framed)
|
|
292
|
+
text = await print_response(client)
|
|
293
|
+
process_response(text, ontology)
|
|
294
|
+
|
|
295
|
+
while True:
|
|
296
|
+
user_input = read_user_input()
|
|
297
|
+
if user_input is None:
|
|
298
|
+
break
|
|
299
|
+
if is_command(user_input):
|
|
300
|
+
msg, new_onto = handle_command(
|
|
301
|
+
user_input, ontology, dag, dag_path,
|
|
302
|
+
)
|
|
303
|
+
print(msg)
|
|
304
|
+
if new_onto is not None:
|
|
305
|
+
ontology = new_onto
|
|
306
|
+
continue
|
|
307
|
+
framed = frame_data("user-input", user_input)
|
|
308
|
+
await client.query(framed)
|
|
309
|
+
text = await print_response(client)
|
|
310
|
+
process_response(text, ontology)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def parse_args(
|
|
314
|
+
argv: list[str] | None = None,
|
|
315
|
+
) -> argparse.Namespace:
|
|
316
|
+
"""Parse command-line arguments."""
|
|
317
|
+
parser = argparse.ArgumentParser(
|
|
318
|
+
description="Interactive ontology discovery agent",
|
|
319
|
+
)
|
|
320
|
+
parser.add_argument(
|
|
321
|
+
"description",
|
|
322
|
+
help="Project description to start discovery",
|
|
323
|
+
)
|
|
324
|
+
parser.add_argument(
|
|
325
|
+
"--dag-file",
|
|
326
|
+
default="ontology.json",
|
|
327
|
+
help="Path to DAG JSON file "
|
|
328
|
+
"(default: ontology.json)",
|
|
329
|
+
)
|
|
330
|
+
parser.add_argument(
|
|
331
|
+
"-m", "--model",
|
|
332
|
+
default="claude-opus-4-6",
|
|
333
|
+
help="Model to use (default: claude-opus-4-6)",
|
|
334
|
+
)
|
|
335
|
+
return parser.parse_args(argv)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def main(argv: list[str] | None = None) -> int:
|
|
339
|
+
"""Entry point for the aofire-discovery-agent CLI."""
|
|
340
|
+
args = parse_args(argv)
|
|
341
|
+
asyncio.run(
|
|
342
|
+
run(args.description, args.model, args.dag_file),
|
|
343
|
+
)
|
|
344
|
+
return 0
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
if __name__ == "__main__":
|
|
348
|
+
sys.exit(main())
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Divergence agent: generates multiple solution candidates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import warnings
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from claude_agent_sdk import (
|
|
14
|
+
AssistantMessage,
|
|
15
|
+
ClaudeAgentOptions,
|
|
16
|
+
ResultMessage,
|
|
17
|
+
query,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from python_agent.agent_utils import (
|
|
21
|
+
collect_response_text,
|
|
22
|
+
extract_ontology_json,
|
|
23
|
+
)
|
|
24
|
+
from python_agent.dag_utils import (
|
|
25
|
+
load_dag,
|
|
26
|
+
make_node_id,
|
|
27
|
+
save_dag,
|
|
28
|
+
)
|
|
29
|
+
from python_agent.ontology import (
|
|
30
|
+
DAGEdge,
|
|
31
|
+
DAGNode,
|
|
32
|
+
Decision,
|
|
33
|
+
Ontology,
|
|
34
|
+
OntologyDAG,
|
|
35
|
+
)
|
|
36
|
+
from python_agent.rules import (
|
|
37
|
+
divergence_system_prompt,
|
|
38
|
+
strategy_system_prompt,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
_STRATEGIES_BLOCK_RE = re.compile(
|
|
42
|
+
r"```strategies\s*\n(.*?)\n```",
|
|
43
|
+
re.DOTALL,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def extract_strategies(
|
|
48
|
+
text: str,
|
|
49
|
+
) -> list[dict[str, Any]] | None:
|
|
50
|
+
"""Extract strategies JSON block from text.
|
|
51
|
+
|
|
52
|
+
Returns a list of strategy dicts, or None.
|
|
53
|
+
"""
|
|
54
|
+
match = _STRATEGIES_BLOCK_RE.search(text)
|
|
55
|
+
if match is None:
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
result = json.loads(match.group(1))
|
|
59
|
+
except json.JSONDecodeError:
|
|
60
|
+
return None
|
|
61
|
+
if not isinstance(result, list):
|
|
62
|
+
return None
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def run_query(
|
|
67
|
+
task: str, options: Any,
|
|
68
|
+
) -> tuple[str, float]:
|
|
69
|
+
"""Run a single query. Return (response_text, cost)."""
|
|
70
|
+
parts: list[str] = []
|
|
71
|
+
cost = 0.0
|
|
72
|
+
async for message in query(
|
|
73
|
+
prompt=task, options=options,
|
|
74
|
+
):
|
|
75
|
+
if isinstance(message, AssistantMessage):
|
|
76
|
+
parts.append(collect_response_text(message))
|
|
77
|
+
elif isinstance(message, ResultMessage):
|
|
78
|
+
cost = message.total_cost_usd or 0.0
|
|
79
|
+
return "\n".join(parts), cost
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_decision(
|
|
83
|
+
strategy: dict[str, Any],
|
|
84
|
+
) -> Decision:
|
|
85
|
+
"""Create a Decision from a strategy dict."""
|
|
86
|
+
options = strategy.get("options", [])
|
|
87
|
+
if not isinstance(options, list):
|
|
88
|
+
warnings.warn(
|
|
89
|
+
f"Non-list options replaced: {type(options)}",
|
|
90
|
+
stacklevel=2,
|
|
91
|
+
)
|
|
92
|
+
options = []
|
|
93
|
+
return Decision(
|
|
94
|
+
question=str(strategy.get("question", "architecture")),
|
|
95
|
+
options=options,
|
|
96
|
+
chosen=str(strategy.get("chosen", "")),
|
|
97
|
+
rationale=str(strategy.get("strategy", "")),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def add_candidate_node(
|
|
102
|
+
dag: OntologyDAG, parent_id: str,
|
|
103
|
+
ontology_dict: dict[str, Any],
|
|
104
|
+
strategy: dict[str, Any],
|
|
105
|
+
) -> str:
|
|
106
|
+
"""Create a DAG node for a candidate and link it."""
|
|
107
|
+
from datetime import datetime, timezone
|
|
108
|
+
now = datetime.now(timezone.utc)
|
|
109
|
+
node_id = make_node_id()
|
|
110
|
+
label: str = strategy.get("label", "candidate")
|
|
111
|
+
ontology = Ontology.model_validate(ontology_dict)
|
|
112
|
+
node = DAGNode(
|
|
113
|
+
id=node_id,
|
|
114
|
+
ontology=ontology,
|
|
115
|
+
created_at=now.isoformat(),
|
|
116
|
+
label=label,
|
|
117
|
+
)
|
|
118
|
+
dag.nodes.append(node)
|
|
119
|
+
edge = DAGEdge(
|
|
120
|
+
parent_id=parent_id,
|
|
121
|
+
child_id=node_id,
|
|
122
|
+
decision=build_decision(strategy),
|
|
123
|
+
created_at=now.isoformat(),
|
|
124
|
+
)
|
|
125
|
+
dag.edges.append(edge)
|
|
126
|
+
return node_id
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def remaining_budget(
|
|
130
|
+
spent: float, max_budget: float | None,
|
|
131
|
+
) -> float | None:
|
|
132
|
+
"""Calculate remaining budget after spending."""
|
|
133
|
+
if max_budget is None:
|
|
134
|
+
return None
|
|
135
|
+
return max_budget - spent
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def identify_strategies(
|
|
139
|
+
ontology_json: str, num_candidates: int,
|
|
140
|
+
model: str, max_budget: float | None,
|
|
141
|
+
) -> tuple[list[dict[str, Any]], float]:
|
|
142
|
+
"""Identify distinct architectural strategies."""
|
|
143
|
+
prompt = strategy_system_prompt(
|
|
144
|
+
ontology_json, num_candidates,
|
|
145
|
+
)
|
|
146
|
+
options = ClaudeAgentOptions(
|
|
147
|
+
model=model,
|
|
148
|
+
system_prompt=prompt,
|
|
149
|
+
allowed_tools=[],
|
|
150
|
+
permission_mode="default",
|
|
151
|
+
max_turns=1,
|
|
152
|
+
max_budget_usd=max_budget,
|
|
153
|
+
)
|
|
154
|
+
task = (
|
|
155
|
+
f"Identify {num_candidates} distinct architectural "
|
|
156
|
+
"strategies for this problem domain."
|
|
157
|
+
)
|
|
158
|
+
text, cost = await run_query(task, options)
|
|
159
|
+
strategies = extract_strategies(text)
|
|
160
|
+
if strategies is None:
|
|
161
|
+
return [], cost
|
|
162
|
+
return strategies[:num_candidates], cost
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def generate_candidate(
|
|
166
|
+
ontology_json: str, strategy: dict[str, Any],
|
|
167
|
+
model: str, max_budget: float | None,
|
|
168
|
+
) -> tuple[dict[str, Any] | None, float]:
|
|
169
|
+
"""Generate one solution candidate for a strategy."""
|
|
170
|
+
label: str = strategy.get("label", "candidate")
|
|
171
|
+
description: str = strategy.get("strategy", "")
|
|
172
|
+
prompt = divergence_system_prompt(
|
|
173
|
+
ontology_json, description,
|
|
174
|
+
)
|
|
175
|
+
options = ClaudeAgentOptions(
|
|
176
|
+
model=model,
|
|
177
|
+
system_prompt=prompt,
|
|
178
|
+
allowed_tools=[],
|
|
179
|
+
permission_mode="default",
|
|
180
|
+
max_turns=1,
|
|
181
|
+
max_budget_usd=max_budget,
|
|
182
|
+
)
|
|
183
|
+
task = (
|
|
184
|
+
f"Generate a complete solution architecture "
|
|
185
|
+
f"following the '{label}' strategy: {description}"
|
|
186
|
+
)
|
|
187
|
+
text, cost = await run_query(task, options)
|
|
188
|
+
result = extract_ontology_json(text)
|
|
189
|
+
return result, cost
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def run(
|
|
193
|
+
dag_file: str, num_candidates: int,
|
|
194
|
+
model: str, max_budget: float | None,
|
|
195
|
+
) -> int:
|
|
196
|
+
"""Run the divergence agent. Returns candidate count."""
|
|
197
|
+
dag = load_dag(dag_file, "unknown")
|
|
198
|
+
node = dag.get_current_node()
|
|
199
|
+
if node is None:
|
|
200
|
+
print("Error: DAG has no current node.")
|
|
201
|
+
return 0
|
|
202
|
+
ontology_json = json.dumps(
|
|
203
|
+
node.ontology.model_dump(), indent=2,
|
|
204
|
+
)
|
|
205
|
+
parent_id = dag.current_node_id
|
|
206
|
+
total_cost = 0.0
|
|
207
|
+
|
|
208
|
+
print(f"Identifying {num_candidates} strategies...")
|
|
209
|
+
strategies, cost = await identify_strategies(
|
|
210
|
+
ontology_json, num_candidates, model, max_budget,
|
|
211
|
+
)
|
|
212
|
+
total_cost += cost
|
|
213
|
+
if not strategies:
|
|
214
|
+
print("Error: Could not identify strategies.")
|
|
215
|
+
return 0
|
|
216
|
+
|
|
217
|
+
generated = await _generate_all(
|
|
218
|
+
dag, parent_id, ontology_json, strategies,
|
|
219
|
+
model, total_cost, max_budget,
|
|
220
|
+
)
|
|
221
|
+
save_dag(dag, dag_file)
|
|
222
|
+
return generated
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def _generate_all(
|
|
226
|
+
dag: OntologyDAG, parent_id: str,
|
|
227
|
+
ontology_json: str,
|
|
228
|
+
strategies: list[dict[str, Any]],
|
|
229
|
+
model: str, total_cost: float,
|
|
230
|
+
max_budget: float | None,
|
|
231
|
+
) -> int:
|
|
232
|
+
"""Generate all candidates and add to DAG."""
|
|
233
|
+
generated = 0
|
|
234
|
+
for strategy in strategies:
|
|
235
|
+
label: str = strategy.get("label", "candidate")
|
|
236
|
+
print(f"Generating candidate: {label}...")
|
|
237
|
+
budget = remaining_budget(total_cost, max_budget)
|
|
238
|
+
result, cost = await generate_candidate(
|
|
239
|
+
ontology_json, strategy, model, budget,
|
|
240
|
+
)
|
|
241
|
+
total_cost += cost
|
|
242
|
+
if result is None:
|
|
243
|
+
print(f" Failed: {label}")
|
|
244
|
+
continue
|
|
245
|
+
add_candidate_node(
|
|
246
|
+
dag, parent_id, result, strategy,
|
|
247
|
+
)
|
|
248
|
+
generated += 1
|
|
249
|
+
print(f" Created: {label}")
|
|
250
|
+
cost_str = f"${total_cost:.4f}"
|
|
251
|
+
print(f"\nDone. {generated} candidates. Cost: {cost_str}")
|
|
252
|
+
return generated
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def parse_args(
|
|
256
|
+
argv: list[str] | None = None,
|
|
257
|
+
) -> argparse.Namespace:
|
|
258
|
+
"""Parse command-line arguments."""
|
|
259
|
+
parser = argparse.ArgumentParser(
|
|
260
|
+
description="Generate divergent solution candidates",
|
|
261
|
+
)
|
|
262
|
+
parser.add_argument(
|
|
263
|
+
"--dag-file",
|
|
264
|
+
required=True,
|
|
265
|
+
help="Path to DAG JSON file",
|
|
266
|
+
)
|
|
267
|
+
parser.add_argument(
|
|
268
|
+
"-n", "--num-candidates",
|
|
269
|
+
type=int,
|
|
270
|
+
default=3,
|
|
271
|
+
help="Number of candidates (default: 3)",
|
|
272
|
+
)
|
|
273
|
+
parser.add_argument(
|
|
274
|
+
"-m", "--model",
|
|
275
|
+
default="claude-sonnet-4-6",
|
|
276
|
+
help="Model to use (default: claude-sonnet-4-6)",
|
|
277
|
+
)
|
|
278
|
+
parser.add_argument(
|
|
279
|
+
"--max-budget",
|
|
280
|
+
type=float,
|
|
281
|
+
default=5.0,
|
|
282
|
+
help="Maximum budget in USD (default: 5.0)",
|
|
283
|
+
)
|
|
284
|
+
return parser.parse_args(argv)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def main(argv: list[str] | None = None) -> int:
|
|
288
|
+
"""Entry point for the aofire-divergence-agent CLI."""
|
|
289
|
+
args = parse_args(argv)
|
|
290
|
+
count = asyncio.run(
|
|
291
|
+
run(
|
|
292
|
+
args.dag_file,
|
|
293
|
+
args.num_candidates,
|
|
294
|
+
args.model,
|
|
295
|
+
args.max_budget,
|
|
296
|
+
),
|
|
297
|
+
)
|
|
298
|
+
return 0 if count > 0 else 1
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
sys.exit(main())
|