forgex-cli 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.
- forgex/__init__.py +3 -0
- forgex/__main__.py +6 -0
- forgex/agents/__init__.py +36 -0
- forgex/agents/architect.py +233 -0
- forgex/agents/base.py +385 -0
- forgex/agents/builder.py +269 -0
- forgex/agents/ensemble.py +334 -0
- forgex/agents/expert_coder.py +283 -0
- forgex/agents/supervisor.py +303 -0
- forgex/cli/__init__.py +1 -0
- forgex/cli/app.py +232 -0
- forgex/cli/build_views.py +94 -0
- forgex/cli/commands/__init__.py +1 -0
- forgex/cli/commands/build.py +63 -0
- forgex/cli/commands/checkpoint.py +41 -0
- forgex/cli/commands/config.py +225 -0
- forgex/cli/commands/context.py +94 -0
- forgex/cli/commands/diff.py +32 -0
- forgex/cli/commands/do_cmd.py +50 -0
- forgex/cli/commands/doctor.py +175 -0
- forgex/cli/commands/fix.py +46 -0
- forgex/cli/commands/init.py +41 -0
- forgex/cli/commands/logs.py +102 -0
- forgex/cli/commands/models.py +144 -0
- forgex/cli/commands/plan.py +103 -0
- forgex/cli/commands/review.py +76 -0
- forgex/cli/commands/rollback.py +65 -0
- forgex/cli/commands/run.py +75 -0
- forgex/cli/commands/status.py +141 -0
- forgex/cli/commands/supervise.py +61 -0
- forgex/cli/commands/task.py +136 -0
- forgex/cli/commands/tools.py +116 -0
- forgex/cli/commands/verify.py +65 -0
- forgex/cli/context_memory.py +62 -0
- forgex/cli/intent.py +209 -0
- forgex/cli/progress.py +101 -0
- forgex/cli/repl.py +377 -0
- forgex/cli/runtime.py +233 -0
- forgex/cli/session.py +95 -0
- forgex/cli/ui.py +113 -0
- forgex/config/__init__.py +53 -0
- forgex/config/loader.py +189 -0
- forgex/config/schema.py +145 -0
- forgex/context/__init__.py +6 -0
- forgex/context/chunker.py +102 -0
- forgex/context/dependency_graph.py +110 -0
- forgex/context/indexer.py +161 -0
- forgex/context/manager.py +174 -0
- forgex/context/retriever.py +99 -0
- forgex/models/__init__.py +25 -0
- forgex/models/base.py +112 -0
- forgex/models/capabilities.py +21 -0
- forgex/models/factory.py +66 -0
- forgex/models/groq.py +13 -0
- forgex/models/local.py +10 -0
- forgex/models/openai_compatible.py +233 -0
- forgex/models/registry.py +81 -0
- forgex/models/router.py +332 -0
- forgex/orchestration/__init__.py +36 -0
- forgex/orchestration/checkpoints.py +117 -0
- forgex/orchestration/engine.py +573 -0
- forgex/orchestration/events.py +101 -0
- forgex/orchestration/lockfile.py +82 -0
- forgex/orchestration/retry.py +65 -0
- forgex/orchestration/state.py +89 -0
- forgex/orchestration/stores.py +90 -0
- forgex/orchestration/workflow.py +103 -0
- forgex/project.py +209 -0
- forgex/prompts/__init__.py +27 -0
- forgex/prompts/architect.md +18 -0
- forgex/prompts/builder.md +20 -0
- forgex/prompts/expert_coder.md +25 -0
- forgex/prompts/reviewer.md +11 -0
- forgex/prompts/supervisor.md +26 -0
- forgex/research/__init__.py +15 -0
- forgex/research/citations.py +16 -0
- forgex/research/engine.py +83 -0
- forgex/research/sources.py +154 -0
- forgex/sandbox/__init__.py +37 -0
- forgex/sandbox/executor.py +418 -0
- forgex/sandbox/permissions.py +67 -0
- forgex/sandbox/policies.py +172 -0
- forgex/schemas/__init__.py +101 -0
- forgex/schemas/agent.py +109 -0
- forgex/schemas/checkpoint.py +19 -0
- forgex/schemas/error.py +125 -0
- forgex/schemas/event.py +76 -0
- forgex/schemas/plan.py +84 -0
- forgex/schemas/project.py +92 -0
- forgex/schemas/research.py +25 -0
- forgex/schemas/result.py +68 -0
- forgex/schemas/task.py +118 -0
- forgex/storage/__init__.py +25 -0
- forgex/storage/database.py +40 -0
- forgex/storage/migrations.py +130 -0
- forgex/storage/repositories.py +492 -0
- forgex/tools/__init__.py +73 -0
- forgex/tools/base.py +212 -0
- forgex/tools/dependencies.py +137 -0
- forgex/tools/filesystem.py +322 -0
- forgex/tools/git.py +239 -0
- forgex/tools/project.py +39 -0
- forgex/tools/search.py +205 -0
- forgex/tools/terminal.py +81 -0
- forgex/tools/testing.py +100 -0
- forgex/tools/verification.py +174 -0
- forgex/utils/__init__.py +15 -0
- forgex/utils/logging.py +28 -0
- forgex/utils/paths.py +48 -0
- forgex/utils/redact.py +44 -0
- forgex_cli-0.1.0.dist-info/METADATA +189 -0
- forgex_cli-0.1.0.dist-info/RECORD +115 -0
- forgex_cli-0.1.0.dist-info/WHEEL +4 -0
- forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
- forgex_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
forgex/__init__.py
ADDED
forgex/__main__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Agent package (spec sections 7, 9, 10, 11, 12, 15)."""
|
|
2
|
+
|
|
3
|
+
from forgex.agents.architect import ArchitectAgent, build_planner, plan_project
|
|
4
|
+
from forgex.agents.base import BaseAgent, ToolLoopResult, extract_json, prefetch_files
|
|
5
|
+
from forgex.agents.builder import BuilderAgent, build_builder, execute_task
|
|
6
|
+
from forgex.agents.ensemble import ReviewerAgent, ensemble_review, synthesize
|
|
7
|
+
from forgex.agents.expert_coder import ExpertCoderAgent, build_coder, review_work
|
|
8
|
+
from forgex.agents.supervisor import (
|
|
9
|
+
SupervisorAgent,
|
|
10
|
+
apply_quality_gates,
|
|
11
|
+
build_supervisor,
|
|
12
|
+
supervise,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ArchitectAgent",
|
|
17
|
+
"BaseAgent",
|
|
18
|
+
"BuilderAgent",
|
|
19
|
+
"ExpertCoderAgent",
|
|
20
|
+
"ReviewerAgent",
|
|
21
|
+
"SupervisorAgent",
|
|
22
|
+
"ToolLoopResult",
|
|
23
|
+
"apply_quality_gates",
|
|
24
|
+
"build_builder",
|
|
25
|
+
"build_coder",
|
|
26
|
+
"build_planner",
|
|
27
|
+
"build_supervisor",
|
|
28
|
+
"ensemble_review",
|
|
29
|
+
"execute_task",
|
|
30
|
+
"extract_json",
|
|
31
|
+
"plan_project",
|
|
32
|
+
"prefetch_files",
|
|
33
|
+
"review_work",
|
|
34
|
+
"supervise",
|
|
35
|
+
"synthesize",
|
|
36
|
+
]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Architect agent (spec section 7).
|
|
2
|
+
|
|
3
|
+
Understand → Analyze → Decompose → Research → Plan → Validate.
|
|
4
|
+
Never writes code: read-only tools, official registry research,
|
|
5
|
+
structured plan output, dependency graph validation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from forgex.agents.base import BaseAgent
|
|
15
|
+
from forgex.config.schema import ForgexConfig
|
|
16
|
+
from forgex.models.base import Message
|
|
17
|
+
from forgex.models.router import ModelRouter
|
|
18
|
+
from forgex.orchestration.events import EventRecorder
|
|
19
|
+
from forgex.orchestration.stores import ProjectStores
|
|
20
|
+
from forgex.orchestration.workflow import transition
|
|
21
|
+
from forgex.research.citations import format_notes
|
|
22
|
+
from forgex.research.engine import ResearchEngine
|
|
23
|
+
from forgex.sandbox.permissions import permission_set_for
|
|
24
|
+
from forgex.schemas.agent import AgentRole
|
|
25
|
+
from forgex.schemas.error import AgentOutputError, ProjectError
|
|
26
|
+
from forgex.schemas.event import EventType
|
|
27
|
+
from forgex.schemas.plan import (
|
|
28
|
+
AnalysisResult,
|
|
29
|
+
ArchitectResult,
|
|
30
|
+
ArchitecturePlan,
|
|
31
|
+
specs_to_tasks,
|
|
32
|
+
)
|
|
33
|
+
from forgex.schemas.project import WorkflowStatus
|
|
34
|
+
from forgex.schemas.task import topological_order
|
|
35
|
+
from forgex.tools import ToolCall, ToolExecutor, build_tool_registry, create_context
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger("forgex.agents.architect")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ArchitectAgent(BaseAgent):
|
|
41
|
+
role = AgentRole.ARCHITECT
|
|
42
|
+
prompt_name = "architect"
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
router: ModelRouter,
|
|
48
|
+
tools: ToolExecutor,
|
|
49
|
+
research: ResearchEngine,
|
|
50
|
+
events: EventRecorder | None = None,
|
|
51
|
+
run_id: str | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__(router=router, events=events, run_id=run_id)
|
|
54
|
+
self._tools = tools
|
|
55
|
+
self._research = research
|
|
56
|
+
|
|
57
|
+
async def _gather_context(self) -> str:
|
|
58
|
+
info_res = await self._tools.execute(ToolCall(name="project_info", arguments={}))
|
|
59
|
+
files_res = await self._tools.execute(ToolCall(name="list_files", arguments={}))
|
|
60
|
+
|
|
61
|
+
brief = f"Repository Overview:\n{info_res.output}\n\nFiles:\n{files_res.output}"
|
|
62
|
+
try:
|
|
63
|
+
from forgex.context.manager import ContextManager
|
|
64
|
+
|
|
65
|
+
symbols = ContextManager(self._tools.context.workspace).symbol_map()
|
|
66
|
+
if symbols:
|
|
67
|
+
brief += "\n\nSymbols:\n" + symbols
|
|
68
|
+
except Exception:
|
|
69
|
+
logger.warning("symbol map unavailable", exc_info=True)
|
|
70
|
+
return brief
|
|
71
|
+
|
|
72
|
+
async def plan(
|
|
73
|
+
self,
|
|
74
|
+
user_request: str,
|
|
75
|
+
*,
|
|
76
|
+
on_stage: Callable[[str], None] | None = None,
|
|
77
|
+
) -> ArchitectResult:
|
|
78
|
+
if self._events:
|
|
79
|
+
self._events.record(
|
|
80
|
+
EventType.AGENT_STARTED,
|
|
81
|
+
{"agent": self.role.value, "request": user_request},
|
|
82
|
+
run_id=self._run_id,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
repo_context = await self._gather_context()
|
|
86
|
+
|
|
87
|
+
# Stage 1: Analysis & research topic selection
|
|
88
|
+
if on_stage:
|
|
89
|
+
on_stage("analyzing")
|
|
90
|
+
|
|
91
|
+
stage1_msg = (
|
|
92
|
+
f"<user_request>\n{user_request}\n</user_request>\n\n"
|
|
93
|
+
f"<repository>\n{repo_context}\n</repository>\n\n"
|
|
94
|
+
"Analyze this request and repository. State the goal, requirements, candidate libraries, and research topics."
|
|
95
|
+
)
|
|
96
|
+
analysis: AnalysisResult = await self._generate_structured(
|
|
97
|
+
[Message(role="user", content=stage1_msg)], AnalysisResult
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Stage 2: Dependency research
|
|
101
|
+
if on_stage:
|
|
102
|
+
on_stage("researching")
|
|
103
|
+
notes = await self._research.research(analysis.research_topics)
|
|
104
|
+
if self._events:
|
|
105
|
+
self._events.record(
|
|
106
|
+
EventType.RESEARCH_COMPLETED,
|
|
107
|
+
{"topics": [n.topic for n in notes], "available": sum(1 for n in notes if n.available)},
|
|
108
|
+
run_id=self._run_id,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Stage 3: Architecture plan creation
|
|
112
|
+
if on_stage:
|
|
113
|
+
on_stage("planning")
|
|
114
|
+
|
|
115
|
+
research_text = format_notes(notes)
|
|
116
|
+
stage2_msg = (
|
|
117
|
+
f"<user_request>\n{user_request}\n</user_request>\n\n"
|
|
118
|
+
f"<repository>\n{repo_context}\n</repository>\n\n"
|
|
119
|
+
f"<analysis>\nGoal: {analysis.goal}\nRequirements: {', '.join(analysis.requirements)}\nCandidate Libraries: {', '.join(analysis.candidate_libraries)}\n</analysis>\n\n"
|
|
120
|
+
f"<research>\n{research_text}\n</research>\n\n"
|
|
121
|
+
"Produce the final implementation plan adhering to the JSON schema. Decompose into focused, verifiable tasks."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
plan: ArchitecturePlan = await self._generate_structured(
|
|
125
|
+
[Message(role="user", content=stage2_msg)], ArchitecturePlan
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Validate plan
|
|
129
|
+
if not plan.tasks:
|
|
130
|
+
raise AgentOutputError("Architect produced an empty plan (no tasks)")
|
|
131
|
+
|
|
132
|
+
# Validate graph: cycle detection, unknown deps, duplicate IDs
|
|
133
|
+
runtime_tasks = specs_to_tasks(plan.tasks)
|
|
134
|
+
topological_order(runtime_tasks)
|
|
135
|
+
|
|
136
|
+
result = ArchitectResult(plan=plan, research=notes, analysis=analysis)
|
|
137
|
+
if self._events:
|
|
138
|
+
self._events.record(
|
|
139
|
+
EventType.AGENT_COMPLETED,
|
|
140
|
+
{"agent": self.role.value, "tasks": len(plan.tasks)},
|
|
141
|
+
run_id=self._run_id,
|
|
142
|
+
)
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
async def aclose(self) -> None:
|
|
146
|
+
await super().aclose()
|
|
147
|
+
await self._research.aclose()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_planner(
|
|
151
|
+
*,
|
|
152
|
+
config: ForgexConfig,
|
|
153
|
+
workspace: Path,
|
|
154
|
+
stores: ProjectStores,
|
|
155
|
+
router: ModelRouter | None = None,
|
|
156
|
+
run_id: str | None = None,
|
|
157
|
+
) -> ArchitectAgent:
|
|
158
|
+
router = router or ModelRouter(config, usage=stores.usage)
|
|
159
|
+
tool_ctx = create_context(
|
|
160
|
+
workspace=workspace,
|
|
161
|
+
config=config,
|
|
162
|
+
events=stores.recorder,
|
|
163
|
+
run_id=run_id,
|
|
164
|
+
agent=AgentRole.ARCHITECT,
|
|
165
|
+
)
|
|
166
|
+
tools = ToolExecutor(
|
|
167
|
+
registry=build_tool_registry(),
|
|
168
|
+
permissions=permission_set_for(AgentRole.ARCHITECT),
|
|
169
|
+
context=tool_ctx,
|
|
170
|
+
)
|
|
171
|
+
research = ResearchEngine(config=config)
|
|
172
|
+
return ArchitectAgent(
|
|
173
|
+
router=router,
|
|
174
|
+
tools=tools,
|
|
175
|
+
research=research,
|
|
176
|
+
events=stores.recorder,
|
|
177
|
+
run_id=run_id,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def plan_project(
|
|
182
|
+
planner: ArchitectAgent,
|
|
183
|
+
*,
|
|
184
|
+
stores: ProjectStores,
|
|
185
|
+
request: str,
|
|
186
|
+
dry_run: bool = False,
|
|
187
|
+
progress: Callable[[str], None] | None = None,
|
|
188
|
+
) -> ArchitectResult:
|
|
189
|
+
state = stores.state.load()
|
|
190
|
+
if state is None:
|
|
191
|
+
raise ProjectError("Project state missing — run `forgex init` first.")
|
|
192
|
+
|
|
193
|
+
# Reset if from completed/failed
|
|
194
|
+
if state.status in (WorkflowStatus.COMPLETED, WorkflowStatus.FAILED, WorkflowStatus.CANCELLED):
|
|
195
|
+
state.status = WorkflowStatus.INITIALIZING
|
|
196
|
+
|
|
197
|
+
if state.status == WorkflowStatus.PLANNING or state.status == WorkflowStatus.INITIALIZING:
|
|
198
|
+
state.status = transition(state.status, WorkflowStatus.ANALYZING)
|
|
199
|
+
|
|
200
|
+
if not dry_run:
|
|
201
|
+
state.user_request = request
|
|
202
|
+
stores.save_state(state)
|
|
203
|
+
|
|
204
|
+
def on_stage(stage: str) -> None:
|
|
205
|
+
if progress:
|
|
206
|
+
progress(stage)
|
|
207
|
+
if not dry_run:
|
|
208
|
+
if stage == "researching" and state.status == WorkflowStatus.ANALYZING:
|
|
209
|
+
state.status = transition(state.status, WorkflowStatus.RESEARCHING)
|
|
210
|
+
stores.save_state(state)
|
|
211
|
+
elif stage == "planning" and state.status in (WorkflowStatus.RESEARCHING, WorkflowStatus.ANALYZING):
|
|
212
|
+
state.status = transition(state.status, WorkflowStatus.PLANNING)
|
|
213
|
+
stores.save_state(state)
|
|
214
|
+
|
|
215
|
+
result = await planner.plan(request, on_stage=on_stage)
|
|
216
|
+
|
|
217
|
+
if not dry_run:
|
|
218
|
+
state.status = WorkflowStatus.PLANNING
|
|
219
|
+
state.architecture_plan = result.plan
|
|
220
|
+
state.research = result.research
|
|
221
|
+
tasks = specs_to_tasks(result.plan.tasks)
|
|
222
|
+
state.tasks = [t.id for t in tasks]
|
|
223
|
+
state.completed_tasks = []
|
|
224
|
+
state.failed_tasks = []
|
|
225
|
+
stores.save_tasks(tasks, project_id=state.project_id)
|
|
226
|
+
stores.save_state(state)
|
|
227
|
+
stores.recorder.record(
|
|
228
|
+
EventType.PLAN_CREATED,
|
|
229
|
+
{"goal": result.plan.goal, "tasks": len(result.plan.tasks)},
|
|
230
|
+
run_id=state.current_run_id,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return result
|
forgex/agents/base.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Agent base classes (spec sections 7, 9, 42, 47, 48, 75).
|
|
2
|
+
|
|
3
|
+
Agents are persistence-free services: they receive a request, tools, and
|
|
4
|
+
a router, and return structured results.
|
|
5
|
+
|
|
6
|
+
The tool loop: bounded multi-turn loop supporting native tool calls
|
|
7
|
+
or JSON protocol fallback. Enforces transcript budgets and secret redaction.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
from abc import ABC
|
|
16
|
+
from collections.abc import Iterable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
20
|
+
|
|
21
|
+
from pydantic import BaseModel, ValidationError
|
|
22
|
+
|
|
23
|
+
from forgex.models.base import Message
|
|
24
|
+
from forgex.orchestration.events import EventRecorder
|
|
25
|
+
from forgex.prompts import load_prompt
|
|
26
|
+
from forgex.schemas.agent import AgentRole
|
|
27
|
+
from forgex.schemas.error import AgentOutputError, PermissionDeniedError
|
|
28
|
+
from forgex.schemas.event import EventType
|
|
29
|
+
from forgex.tools import ToolExecutor, ToolResult
|
|
30
|
+
from forgex.tools.base import ToolCall as SystemToolCall
|
|
31
|
+
from forgex.tools.filesystem import is_secret_file
|
|
32
|
+
from forgex.utils.paths import PathViolationError, resolve_in_workspace
|
|
33
|
+
from forgex.utils.redact import redact_patterns
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from forgex.models.router import ModelRouter
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("forgex.agents")
|
|
39
|
+
|
|
40
|
+
ModelT = TypeVar("ModelT", bound=BaseModel)
|
|
41
|
+
|
|
42
|
+
_FENCE_RE = re.compile(r"```(?:json)?\s*(.+?)\s*```", re.DOTALL)
|
|
43
|
+
|
|
44
|
+
MAX_TOOL_RESULT_CHARS = 8_000
|
|
45
|
+
MAX_PROTOCOL_ERRORS = 3
|
|
46
|
+
MAX_TRANSCRIPT_CHARS = 150_000
|
|
47
|
+
_SOFT_BUDGET_RATIO = 0.75
|
|
48
|
+
|
|
49
|
+
PROTOCOL_MODE_DOC = """
|
|
50
|
+
Tool protocol — this model does not support native tool calls.
|
|
51
|
+
To call a tool, respond with ONLY:
|
|
52
|
+
{"action": "tool", "tool": "<tool name>", "arguments": {<arguments>}}
|
|
53
|
+
The tool result is returned to you as JSON in the next message.
|
|
54
|
+
To finish, respond with ONLY:
|
|
55
|
+
{"action": "done", "result": {<final report matching the schema>}}
|
|
56
|
+
|
|
57
|
+
Available tools (JSON Schemas):
|
|
58
|
+
{tool_schemas}
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def extract_json(text: str) -> Any:
|
|
63
|
+
"""Extract JSON from model output: raw, fenced, or embedded in prose."""
|
|
64
|
+
text = text.strip()
|
|
65
|
+
try:
|
|
66
|
+
return json.loads(text)
|
|
67
|
+
except ValueError:
|
|
68
|
+
pass
|
|
69
|
+
match = _FENCE_RE.search(text)
|
|
70
|
+
if match:
|
|
71
|
+
try:
|
|
72
|
+
return json.loads(match.group(1))
|
|
73
|
+
except ValueError:
|
|
74
|
+
pass
|
|
75
|
+
for opener, closer in (("{", "}"), ("[", "]")):
|
|
76
|
+
start, end = text.find(opener), text.rfind(closer)
|
|
77
|
+
if start != -1 and end > start:
|
|
78
|
+
try:
|
|
79
|
+
return json.loads(text[start : end + 1])
|
|
80
|
+
except ValueError:
|
|
81
|
+
continue
|
|
82
|
+
raise ValueError("no JSON object found in model output")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def prefetch_files(
|
|
86
|
+
workspace: Path,
|
|
87
|
+
files: Iterable[str],
|
|
88
|
+
*,
|
|
89
|
+
budget: int,
|
|
90
|
+
max_files: int | None = None,
|
|
91
|
+
) -> str:
|
|
92
|
+
"""Prefetch existing files' contents into an instruction block."""
|
|
93
|
+
sections: list[str] = []
|
|
94
|
+
remaining = budget
|
|
95
|
+
for rel in files:
|
|
96
|
+
if remaining <= 0:
|
|
97
|
+
break
|
|
98
|
+
if max_files is not None and len(sections) >= max_files:
|
|
99
|
+
break
|
|
100
|
+
if is_secret_file(Path(rel).name):
|
|
101
|
+
continue
|
|
102
|
+
try:
|
|
103
|
+
path = resolve_in_workspace(workspace, rel)
|
|
104
|
+
except PathViolationError:
|
|
105
|
+
continue
|
|
106
|
+
if not path.is_file():
|
|
107
|
+
continue
|
|
108
|
+
try:
|
|
109
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
110
|
+
except OSError:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
content = redact_patterns(content)
|
|
114
|
+
if len(content) > remaining:
|
|
115
|
+
content = content[:remaining] + "\n... [trimmed]"
|
|
116
|
+
sections.append(f"--- {rel} ---\n{content}")
|
|
117
|
+
remaining -= len(content)
|
|
118
|
+
return "\n\n".join(sections)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _transcript_chars(messages: list[Message]) -> int:
|
|
122
|
+
total = 0
|
|
123
|
+
for m in messages:
|
|
124
|
+
total += len(m.content or "")
|
|
125
|
+
if m.tool_calls:
|
|
126
|
+
total += len(json.dumps(m.tool_calls))
|
|
127
|
+
return total
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class ToolLoopResult:
|
|
132
|
+
report: Any
|
|
133
|
+
rounds: int
|
|
134
|
+
tool_calls_count: int
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class BaseAgent(ABC):
|
|
138
|
+
role: AgentRole
|
|
139
|
+
prompt_name: str
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
*,
|
|
144
|
+
router: ModelRouter,
|
|
145
|
+
events: EventRecorder | None = None,
|
|
146
|
+
run_id: str | None = None,
|
|
147
|
+
temperature: float = 0.2,
|
|
148
|
+
max_output_attempts: int = 2,
|
|
149
|
+
) -> None:
|
|
150
|
+
self._router = router
|
|
151
|
+
self._events = events
|
|
152
|
+
self._run_id = run_id
|
|
153
|
+
self.temperature = temperature
|
|
154
|
+
self.max_output_attempts = max_output_attempts
|
|
155
|
+
|
|
156
|
+
def _system_prompt(self, target_schema: type[BaseModel] | None = None) -> str:
|
|
157
|
+
prompt_text = load_prompt(self.prompt_name)
|
|
158
|
+
if target_schema is not None:
|
|
159
|
+
schema_json = json.dumps(target_schema.model_json_schema(), indent=2)
|
|
160
|
+
prompt_text += (
|
|
161
|
+
f"\n\nYou must respond with JSON adhering to this schema:\n```json\n{schema_json}\n```\n"
|
|
162
|
+
)
|
|
163
|
+
return prompt_text
|
|
164
|
+
|
|
165
|
+
async def _generate_structured(
|
|
166
|
+
self,
|
|
167
|
+
messages: list[Message],
|
|
168
|
+
target_schema: type[ModelT],
|
|
169
|
+
*,
|
|
170
|
+
agent_role: str | None = None,
|
|
171
|
+
) -> ModelT:
|
|
172
|
+
role_key = agent_role or self.role.value
|
|
173
|
+
caps = self._router.capabilities_for(role_key)
|
|
174
|
+
system = self._system_prompt(target_schema)
|
|
175
|
+
|
|
176
|
+
if self._events:
|
|
177
|
+
self._events.record(
|
|
178
|
+
EventType.AGENT_THINKING,
|
|
179
|
+
{"agent": role_key, "stage": "structured_generation"},
|
|
180
|
+
run_id=self._run_id,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
msgs = list(messages)
|
|
184
|
+
rf = {"type": "json_object"} if caps.supports_json else None
|
|
185
|
+
|
|
186
|
+
for attempt in range(1, self.max_output_attempts + 1):
|
|
187
|
+
resp = await self._router.generate(
|
|
188
|
+
role_key,
|
|
189
|
+
messages=msgs,
|
|
190
|
+
system=system,
|
|
191
|
+
response_format=rf,
|
|
192
|
+
temperature=self.temperature,
|
|
193
|
+
run_id=self._run_id,
|
|
194
|
+
)
|
|
195
|
+
raw = resp.content or ""
|
|
196
|
+
try:
|
|
197
|
+
data = extract_json(raw)
|
|
198
|
+
return target_schema.model_validate(data)
|
|
199
|
+
except (ValueError, ValidationError) as exc:
|
|
200
|
+
if attempt >= self.max_output_attempts:
|
|
201
|
+
raise AgentOutputError(
|
|
202
|
+
f"{self.role.value} failed to produce valid JSON: {exc}\nRaw: {raw[:300]}"
|
|
203
|
+
) from exc
|
|
204
|
+
logger.warning(
|
|
205
|
+
"%s structured output attempt %d failed (%s); retrying",
|
|
206
|
+
self.role.value,
|
|
207
|
+
attempt,
|
|
208
|
+
exc,
|
|
209
|
+
)
|
|
210
|
+
msgs.append(Message(role="assistant", content=raw))
|
|
211
|
+
msgs.append(
|
|
212
|
+
Message(
|
|
213
|
+
role="user",
|
|
214
|
+
content=f"Your previous response did not match the required JSON schema ({exc}). Please reply with ONLY valid JSON.",
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
raise AgentOutputError(f"{self.role.value} exceeded output attempts")
|
|
219
|
+
|
|
220
|
+
async def _tool_loop(
|
|
221
|
+
self,
|
|
222
|
+
initial_instruction: str,
|
|
223
|
+
tools: ToolExecutor,
|
|
224
|
+
target_schema: type[ModelT],
|
|
225
|
+
*,
|
|
226
|
+
max_rounds: int = 16,
|
|
227
|
+
agent_role: str | None = None,
|
|
228
|
+
) -> ToolLoopResult:
|
|
229
|
+
role_key = agent_role or self.role.value
|
|
230
|
+
caps = self._router.capabilities_for(role_key)
|
|
231
|
+
system = self._system_prompt(target_schema)
|
|
232
|
+
|
|
233
|
+
messages: list[Message] = [Message(role="user", content=initial_instruction)]
|
|
234
|
+
rounds = 0
|
|
235
|
+
total_tool_calls = 0
|
|
236
|
+
warned = False
|
|
237
|
+
|
|
238
|
+
if caps.supports_tools:
|
|
239
|
+
# Native tool-calling mode
|
|
240
|
+
available_schemas = tools.schemas()
|
|
241
|
+
while rounds < max_rounds:
|
|
242
|
+
rounds += 1
|
|
243
|
+
size = _transcript_chars(messages)
|
|
244
|
+
if size > MAX_TRANSCRIPT_CHARS:
|
|
245
|
+
raise AgentOutputError(f"{self.role.value} exceeded context budget ({MAX_TRANSCRIPT_CHARS} chars)")
|
|
246
|
+
if not warned and size > MAX_TRANSCRIPT_CHARS * _SOFT_BUDGET_RATIO:
|
|
247
|
+
warned = True
|
|
248
|
+
messages.append(
|
|
249
|
+
Message(role="user", content="CONTEXT LIMIT APPROACHING: finish current step and produce your final JSON report now.")
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if self._events:
|
|
253
|
+
self._events.record(
|
|
254
|
+
EventType.AGENT_THINKING,
|
|
255
|
+
{"agent": role_key, "round": rounds},
|
|
256
|
+
run_id=self._run_id,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
resp = await self._router.generate(
|
|
260
|
+
role_key,
|
|
261
|
+
messages=messages,
|
|
262
|
+
system=system,
|
|
263
|
+
tools=available_schemas,
|
|
264
|
+
temperature=self.temperature,
|
|
265
|
+
run_id=self._run_id,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if resp.tool_calls:
|
|
269
|
+
# Convert to wire format for message history
|
|
270
|
+
tc_dicts = [
|
|
271
|
+
{
|
|
272
|
+
"id": tc.id,
|
|
273
|
+
"type": "function",
|
|
274
|
+
"function": {"name": tc.name, "arguments": tc.arguments},
|
|
275
|
+
}
|
|
276
|
+
for tc in resp.tool_calls
|
|
277
|
+
]
|
|
278
|
+
messages.append(Message(role="assistant", content=resp.content, tool_calls=tc_dicts))
|
|
279
|
+
|
|
280
|
+
for tc in resp.tool_calls:
|
|
281
|
+
total_tool_calls += 1
|
|
282
|
+
try:
|
|
283
|
+
args = json.loads(tc.arguments) if tc.arguments else {}
|
|
284
|
+
except ValueError:
|
|
285
|
+
args = {}
|
|
286
|
+
t_call = SystemToolCall(id=tc.id, name=tc.name, arguments=args)
|
|
287
|
+
try:
|
|
288
|
+
t_res = await tools.execute(t_call)
|
|
289
|
+
except PermissionDeniedError as exc:
|
|
290
|
+
t_res = ToolResult(ok=False, denied=True, error=str(exc))
|
|
291
|
+
out_text = t_res.output or (f"Error: {t_res.error}" if t_res.error else "done")
|
|
292
|
+
if len(out_text) > MAX_TOOL_RESULT_CHARS:
|
|
293
|
+
out_text = out_text[:MAX_TOOL_RESULT_CHARS] + "\n... [truncated]"
|
|
294
|
+
messages.append(
|
|
295
|
+
Message(role="tool", tool_call_id=tc.id, name=tc.name, content=out_text)
|
|
296
|
+
)
|
|
297
|
+
else:
|
|
298
|
+
# Final report produced
|
|
299
|
+
raw = resp.content or ""
|
|
300
|
+
try:
|
|
301
|
+
data = extract_json(raw)
|
|
302
|
+
report = target_schema.model_validate(data)
|
|
303
|
+
return ToolLoopResult(report=report, rounds=rounds, tool_calls_count=total_tool_calls)
|
|
304
|
+
except (ValueError, ValidationError) as exc:
|
|
305
|
+
# Bounded repair
|
|
306
|
+
messages.append(Message(role="assistant", content=raw))
|
|
307
|
+
messages.append(
|
|
308
|
+
Message(
|
|
309
|
+
role="user",
|
|
310
|
+
content=f"Your final response did not match the JSON schema ({exc}). Please return ONLY the final JSON object.",
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
repair_resp = await self._router.generate(
|
|
314
|
+
role_key,
|
|
315
|
+
messages=messages,
|
|
316
|
+
system=system,
|
|
317
|
+
temperature=self.temperature,
|
|
318
|
+
run_id=self._run_id,
|
|
319
|
+
)
|
|
320
|
+
data = extract_json(repair_resp.content or "")
|
|
321
|
+
report = target_schema.model_validate(data)
|
|
322
|
+
return ToolLoopResult(report=report, rounds=rounds, tool_calls_count=total_tool_calls)
|
|
323
|
+
|
|
324
|
+
# Loop limit reached; ask for final report
|
|
325
|
+
final = await self._generate_structured(messages, target_schema, agent_role=role_key)
|
|
326
|
+
return ToolLoopResult(report=final, rounds=rounds, tool_calls_count=total_tool_calls)
|
|
327
|
+
|
|
328
|
+
else:
|
|
329
|
+
# JSON Protocol mode for models without tool support
|
|
330
|
+
protocol_doc = PROTOCOL_MODE_DOC.format(
|
|
331
|
+
tool_schemas=json.dumps(tools.schemas(), indent=2)
|
|
332
|
+
)
|
|
333
|
+
protocol_system = system + "\n\n" + protocol_doc
|
|
334
|
+
protocol_errors = 0
|
|
335
|
+
|
|
336
|
+
while rounds < max_rounds:
|
|
337
|
+
rounds += 1
|
|
338
|
+
size = _transcript_chars(messages)
|
|
339
|
+
if size > MAX_TRANSCRIPT_CHARS:
|
|
340
|
+
raise AgentOutputError(f"{self.role.value} exceeded context budget")
|
|
341
|
+
|
|
342
|
+
resp = await self._router.generate(
|
|
343
|
+
role_key,
|
|
344
|
+
messages=messages,
|
|
345
|
+
system=protocol_system,
|
|
346
|
+
temperature=self.temperature,
|
|
347
|
+
run_id=self._run_id,
|
|
348
|
+
)
|
|
349
|
+
raw = resp.content or ""
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
action_data = extract_json(raw)
|
|
353
|
+
except ValueError:
|
|
354
|
+
protocol_errors += 1
|
|
355
|
+
if protocol_errors >= MAX_PROTOCOL_ERRORS:
|
|
356
|
+
raise AgentOutputError(f"{self.role.value} protocol errors exceeded: {raw[:200]}")
|
|
357
|
+
messages.append(Message(role="assistant", content=raw))
|
|
358
|
+
messages.append(Message(role="user", content="Please reply with a valid JSON action: {\"action\": \"tool\", ...} or {\"action\": \"done\", ...}."))
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
action = action_data.get("action")
|
|
362
|
+
if action == "tool":
|
|
363
|
+
tool_name = action_data.get("tool")
|
|
364
|
+
args = action_data.get("arguments") or {}
|
|
365
|
+
total_tool_calls += 1
|
|
366
|
+
t_res = await tools.execute(SystemToolCall(name=tool_name, arguments=args))
|
|
367
|
+
out_text = t_res.output or (f"Error: {t_res.error}" if t_res.error else "done")
|
|
368
|
+
if len(out_text) > MAX_TOOL_RESULT_CHARS:
|
|
369
|
+
out_text = out_text[:MAX_TOOL_RESULT_CHARS] + "\n... [truncated]"
|
|
370
|
+
messages.append(Message(role="assistant", content=raw))
|
|
371
|
+
messages.append(Message(role="user", content=json.dumps({"tool_result": out_text})))
|
|
372
|
+
elif action == "done":
|
|
373
|
+
res_data = action_data.get("result") or {}
|
|
374
|
+
report = target_schema.model_validate(res_data)
|
|
375
|
+
return ToolLoopResult(report=report, rounds=rounds, tool_calls_count=total_tool_calls)
|
|
376
|
+
else:
|
|
377
|
+
protocol_errors += 1
|
|
378
|
+
messages.append(Message(role="assistant", content=raw))
|
|
379
|
+
messages.append(Message(role="user", content="Unknown action. Use 'tool' or 'done'."))
|
|
380
|
+
|
|
381
|
+
final = await self._generate_structured(messages, target_schema, agent_role=role_key)
|
|
382
|
+
return ToolLoopResult(report=final, rounds=rounds, tool_calls_count=total_tool_calls)
|
|
383
|
+
|
|
384
|
+
async def aclose(self) -> None:
|
|
385
|
+
await self._router.aclose()
|