opencommand 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.
- opencommand/__init__.py +4 -0
- opencommand/cli.py +375 -0
- opencommand/core/agent.py +91 -0
- opencommand/core/config.py +65 -0
- opencommand/core/errors.py +23 -0
- opencommand/core/logging.py +65 -0
- opencommand/core/supervisor.py +81 -0
- opencommand/core/tools.py +136 -0
- opencommand/engine/__init__.py +66 -0
- opencommand/engine/runner.py +275 -0
- opencommand/modules/knowledge.py +85 -0
- opencommand/modules/memory/MEMORY.md +14 -0
- opencommand/modules/skills/bash.md +14 -0
- opencommand/modules/skills/powershell.md +15 -0
- opencommand/modules/skills/python.md +20 -0
- opencommand/modules/skills/system.md +38 -0
- opencommand/modules/system/automatic.py +151 -0
- opencommand/modules/system/cron.py +193 -0
- opencommand/modules/system/notify.py +85 -0
- opencommand/modules/system/platform.py +197 -0
- opencommand/modules/templates.py +227 -0
- opencommand/modules/tools/desktop.py +127 -0
- opencommand/modules/tools/files.py +82 -0
- opencommand/modules/tools/instructions.py +163 -0
- opencommand/modules/tools/memory.py +78 -0
- opencommand/modules/tools/playwright.py +61 -0
- opencommand/modules/tools/research.py +46 -0
- opencommand/modules/tools/system.py +163 -0
- opencommand/modules/tools/terminal.py +53 -0
- opencommand/providers/provider.py +157 -0
- opencommand/swarm/agents/commander.py +530 -0
- opencommand/swarm/agents/worker.py +26 -0
- opencommand/tui/dashboard.py +41 -0
- opencommand-0.1.0.dist-info/METADATA +76 -0
- opencommand-0.1.0.dist-info/RECORD +37 -0
- opencommand-0.1.0.dist-info/WHEEL +4 -0
- opencommand-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Unified LLM provider layer for OpenCommand.
|
|
2
|
+
|
|
3
|
+
All four target backends (Ollama, LM Studio, vLLM, llama.cpp) expose the OpenAI
|
|
4
|
+
Chat Completions API, so a single client with per-backend presets covers them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, AsyncIterator, Iterator
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from openai import AsyncOpenAI, OpenAI
|
|
14
|
+
except Exception: # pragma: no cover - openai is a hard dep but keep import soft
|
|
15
|
+
AsyncOpenAI = None # type: ignore
|
|
16
|
+
OpenAI = None # type: ignore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class BackendPreset:
|
|
21
|
+
"""Connection defaults for a local LLM server."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
base_url: str
|
|
25
|
+
default_model: str
|
|
26
|
+
api_key: str = "not-needed"
|
|
27
|
+
notes: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Presets for the four supported OpenAI-compatible servers.
|
|
31
|
+
BACKENDS: dict[str, BackendPreset] = {
|
|
32
|
+
"ollama": BackendPreset("ollama", "http://localhost:11434/v1", "qwen2.5:7b",
|
|
33
|
+
notes="ollama serve; pull models with `ollama pull`."),
|
|
34
|
+
"lmstudio": BackendPreset("lmstudio", "http://localhost:1234/v1",
|
|
35
|
+
"lmstudio-community/qwen2.5-7b",
|
|
36
|
+
notes="LM Studio -> Developer tab -> Start Server."),
|
|
37
|
+
"vllm": BackendPreset("vllm", "http://localhost:8000/v1",
|
|
38
|
+
"Qwen/Qwen2.5-7B-Instruct", notes="vllm serve <model> --api-key token"),
|
|
39
|
+
"llamacpp": BackendPreset("llamacpp", "http://localhost:8080/v1", "local-model",
|
|
40
|
+
notes="server --api-key token (llama.cpp server)."),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ChatMessage:
|
|
46
|
+
role: str
|
|
47
|
+
content: Any # str or list[dict] for multimodal
|
|
48
|
+
name: str | None = None
|
|
49
|
+
tool_calls: list | None = None # OpenAI-style tool calls (assistant turns)
|
|
50
|
+
tool_call_id: str | None = None # links a tool result back to its call
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ProviderConfig:
|
|
55
|
+
backend: str = "ollama"
|
|
56
|
+
base_url: str | None = None
|
|
57
|
+
api_key: str | None = None
|
|
58
|
+
model: str | None = None
|
|
59
|
+
temperature: float = 0.2
|
|
60
|
+
max_tokens: int = 4096
|
|
61
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
def resolve(self) -> BackendPreset:
|
|
64
|
+
preset = BACKENDS.get(self.backend)
|
|
65
|
+
if preset is None:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"Unknown backend '{self.backend}'. "
|
|
68
|
+
f"Choose from: {', '.join(BACKENDS)}"
|
|
69
|
+
)
|
|
70
|
+
return preset
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def effective_base_url(self) -> str:
|
|
74
|
+
return self.base_url or self.resolve().base_url
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def effective_model(self) -> str:
|
|
78
|
+
return self.model or self.resolve().default_model
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def effective_api_key(self) -> str:
|
|
82
|
+
return self.api_key or self.resolve().api_key
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class BaseProvider:
|
|
86
|
+
"""Interface every provider implements."""
|
|
87
|
+
|
|
88
|
+
def complete(self, messages: list[ChatMessage], **kw) -> ChatMessage: # sync
|
|
89
|
+
raise NotImplementedError
|
|
90
|
+
|
|
91
|
+
async def acomplete(self, messages: list[ChatMessage], **kw) -> ChatMessage:
|
|
92
|
+
raise NotImplementedError
|
|
93
|
+
|
|
94
|
+
def stream(self, messages: list[ChatMessage], **kw) -> Iterator[ChatMessage]:
|
|
95
|
+
raise NotImplementedError
|
|
96
|
+
|
|
97
|
+
async def astream(self, messages: list[ChatMessage], **kw) -> AsyncIterator[ChatMessage]:
|
|
98
|
+
raise NotImplementedError
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class OpenAICompatibleProvider(BaseProvider):
|
|
102
|
+
"""Talks to any OpenAI-compatible server (Ollama/LM Studio/vLLM/llama.cpp)."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, cfg: ProviderConfig) -> None:
|
|
105
|
+
self.cfg = cfg
|
|
106
|
+
if OpenAI is None:
|
|
107
|
+
raise RuntimeError("openai package is required for the provider layer")
|
|
108
|
+
self._client = OpenAI(
|
|
109
|
+
base_url=cfg.effective_base_url,
|
|
110
|
+
api_key=cfg.effective_api_key,
|
|
111
|
+
)
|
|
112
|
+
self._aclient = AsyncOpenAI(
|
|
113
|
+
base_url=cfg.effective_base_url,
|
|
114
|
+
api_key=cfg.effective_api_key,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _payload(self, messages: list[ChatMessage], **kw) -> dict[str, Any]:
|
|
118
|
+
payload = {
|
|
119
|
+
"model": self.cfg.effective_model,
|
|
120
|
+
"messages": [m.__dict__ for m in messages],
|
|
121
|
+
"temperature": kw.get("temperature", self.cfg.temperature),
|
|
122
|
+
"max_tokens": kw.get("max_tokens", self.cfg.max_tokens),
|
|
123
|
+
}
|
|
124
|
+
payload.update(self.cfg.extra)
|
|
125
|
+
payload.update({k: v for k, v in kw.items() if k not in payload})
|
|
126
|
+
return payload
|
|
127
|
+
|
|
128
|
+
def complete(self, messages: list[ChatMessage], **kw) -> ChatMessage:
|
|
129
|
+
resp = self._client.chat.completions.create(**self._payload(messages, **kw))
|
|
130
|
+
msg = resp.choices[0].message
|
|
131
|
+
return ChatMessage(role=msg.role, content=msg.content or "")
|
|
132
|
+
|
|
133
|
+
async def acomplete(self, messages: list[ChatMessage], **kw) -> ChatMessage:
|
|
134
|
+
resp = await self._aclient.chat.completions.create(**self._payload(messages, **kw))
|
|
135
|
+
msg = resp.choices[0].message
|
|
136
|
+
return ChatMessage(role=msg.role, content=msg.content or "")
|
|
137
|
+
|
|
138
|
+
def stream(self, messages: list[ChatMessage], **kw):
|
|
139
|
+
stream = self._client.chat.completions.create(
|
|
140
|
+
**self._payload(messages, **kw), stream=True
|
|
141
|
+
)
|
|
142
|
+
for chunk in stream:
|
|
143
|
+
if chunk.choices and chunk.choices[0].delta.content:
|
|
144
|
+
yield ChatMessage(role="assistant", content=chunk.choices[0].delta.content)
|
|
145
|
+
|
|
146
|
+
async def astream(self, messages: list[ChatMessage], **kw):
|
|
147
|
+
stream = await self._aclient.chat.completions.create(
|
|
148
|
+
**self._payload(messages, **kw), stream=True
|
|
149
|
+
)
|
|
150
|
+
async for chunk in stream:
|
|
151
|
+
if chunk.choices and chunk.choices[0].delta.content:
|
|
152
|
+
yield ChatMessage(role="assistant", content=chunk.choices[0].delta.content)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def build_provider(cfg: ProviderConfig) -> BaseProvider:
|
|
156
|
+
"""Factory: today every backend is OpenAI-compatible."""
|
|
157
|
+
return OpenAICompatibleProvider(cfg)
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""Commander: the orchestrating model.
|
|
2
|
+
|
|
3
|
+
Takes a high-level GOAL, plans a multi-phase implementation, and dispatches
|
|
4
|
+
batches of research and worker agents via the Supervisor. Updates memory and
|
|
5
|
+
iterates until the goal and all subtasks are complete.
|
|
6
|
+
|
|
7
|
+
Phase 6 advanced pipeline (orchestrator-workers + evaluator-optimizer), hardened
|
|
8
|
+
for codebase-scale, fully-automated, long-horizon goals:
|
|
9
|
+
|
|
10
|
+
scaffold -> RESEARCH LOOP (refine until accurate) -> implement (workers) ->
|
|
11
|
+
TEST+DEBUG LOOP (until testable state passes) -> commander review gate
|
|
12
|
+
(evaluator-optimizer, incl. off-track check) -> live-verify (optional) ->
|
|
13
|
+
outer loop until the GOAL is 100% met.
|
|
14
|
+
|
|
15
|
+
Research is looped *before* any worker is dispatched so implementation is always
|
|
16
|
+
grounded in verified findings. Guardrails are injected into every prompt so the
|
|
17
|
+
Commander and workers never diverge from the assigned task or the workspace.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
from ...core.agent import AgentLoop
|
|
28
|
+
from ...core.logging import get_logger
|
|
29
|
+
from ...core.supervisor import Supervisor
|
|
30
|
+
from ...core.tools import ToolRegistry
|
|
31
|
+
from ...engine import Engine
|
|
32
|
+
from ...engine.runner import LocalRunner
|
|
33
|
+
from ...providers.provider import ChatMessage
|
|
34
|
+
from ...modules.knowledge import KnowledgeBase
|
|
35
|
+
from ...modules.tools.memory import save_note
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from ...modules.system.automatic import LiveVerifyResult
|
|
39
|
+
|
|
40
|
+
log = get_logger("swarm.commander")
|
|
41
|
+
|
|
42
|
+
PLAN_PROMPT = (
|
|
43
|
+
"You are the Commander of an OpenCommand swarm. Given a GOAL, produce a "
|
|
44
|
+
"multi-phase plan as JSON: {\"phases\": [{\"name\": str, \"research\": [task], "
|
|
45
|
+
"\"workers\": [task]}]}. Keep each task concrete and independently executable. "
|
|
46
|
+
"Prefer research batches first (find best packages/tools/setups), then worker "
|
|
47
|
+
"batches that implement. Respond with JSON only."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
REVIEW_PROMPT = (
|
|
51
|
+
"You are the Commander reviewing a completed phase against the original GOAL. "
|
|
52
|
+
"Decide whether the work actually satisfies the goal. Be strict: tests passing "
|
|
53
|
+
"is not enough if the wrong thing was built, requirements are missing, the "
|
|
54
|
+
"output is incomplete, or the work went OFF TRACK (modified unrelated files, "
|
|
55
|
+
"diverged from the goal, or re-planned the overall goal). Respond with JSON "
|
|
56
|
+
"only, using this shape:\n"
|
|
57
|
+
"{{\"passed\": true_or_false, \"off_track\": true_or_false, \"feedback\": "
|
|
58
|
+
"\"string\", \"missing\": [\"item\"]}}\n\n"
|
|
59
|
+
"GOAL: {goal}\nPHASE: {phase}\n\nWORK DONE:\n{report}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
RESEARCH_ASSESS_PROMPT = (
|
|
63
|
+
"You are the Commander assessing whether the research collected so far is "
|
|
64
|
+
"sufficient to implement the phase ACCURATELY. If there are gaps, unverified "
|
|
65
|
+
"claims, or unknowns that could make workers guess or go off track, list them "
|
|
66
|
+
"as concrete follow-up research tasks. Respond with JSON only, using this "
|
|
67
|
+
"shape:\n"
|
|
68
|
+
"{{\"sufficient\": true_or_false, \"gaps\": [\"follow-up research task\"], "
|
|
69
|
+
"\"note\": \"string\"}}\n\n"
|
|
70
|
+
"GOAL: {goal}\nPHASE: {phase}\n\nRESEARCH SO FAR:\n{research}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Injected into every research/worker prompt so the swarm stays on task and
|
|
74
|
+
# inside the workspace. The files tool also hard-rejects path escapes.
|
|
75
|
+
GUARDRAILS = (
|
|
76
|
+
"GUARDRAILS (strict, must follow):\n"
|
|
77
|
+
"- Stay strictly within the assigned task and the workspace. Do NOT modify "
|
|
78
|
+
"files unrelated to this task or phase.\n"
|
|
79
|
+
"- Do NOT re-plan the overall goal; implement only what is asked of you.\n"
|
|
80
|
+
"- Prefer running real commands/tests over claiming success. If a tool fails, "
|
|
81
|
+
"retry or report the error; never fabricate results.\n"
|
|
82
|
+
"- Stop when the task is complete; emit a final summary with no tool calls.\n"
|
|
83
|
+
"- All file writes must resolve under the workspace root (the files tool "
|
|
84
|
+
"rejects any path that escapes it)."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Phase:
|
|
90
|
+
name: str
|
|
91
|
+
research: list[str] = field(default_factory=list)
|
|
92
|
+
workers: list[str] = field(default_factory=list)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Plan:
|
|
97
|
+
phases: list[Phase] = field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_json(cls, text: str) -> "Plan":
|
|
101
|
+
try:
|
|
102
|
+
data = json.loads(text)
|
|
103
|
+
except json.JSONDecodeError:
|
|
104
|
+
start = text.find("{")
|
|
105
|
+
end = text.rfind("}") + 1
|
|
106
|
+
data = json.loads(text[start:end]) if start != -1 else {"phases": []}
|
|
107
|
+
return cls(phases=[
|
|
108
|
+
Phase(name=p.get("name", "phase"), research=p.get("research", []),
|
|
109
|
+
workers=p.get("workers", []))
|
|
110
|
+
for p in data.get("phases", [])
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class ReviewResult:
|
|
116
|
+
passed: bool = False
|
|
117
|
+
off_track: bool = False
|
|
118
|
+
feedback: str = ""
|
|
119
|
+
missing: list[str] = field(default_factory=list)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_json(cls, text: str) -> "ReviewResult":
|
|
123
|
+
try:
|
|
124
|
+
data = json.loads(text)
|
|
125
|
+
except json.JSONDecodeError:
|
|
126
|
+
start = text.find("{")
|
|
127
|
+
end = text.rfind("}") + 1
|
|
128
|
+
data = json.loads(text[start:end]) if start != -1 else {}
|
|
129
|
+
return cls(
|
|
130
|
+
passed=bool(data.get("passed", False)),
|
|
131
|
+
off_track=bool(data.get("off_track", False)),
|
|
132
|
+
feedback=str(data.get("feedback", "")),
|
|
133
|
+
missing=list(data.get("missing", []) or []),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class ResearchAssessment:
|
|
139
|
+
sufficient: bool = False
|
|
140
|
+
gaps: list[str] = field(default_factory=list)
|
|
141
|
+
note: str = ""
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def from_json(cls, text: str) -> "ResearchAssessment":
|
|
145
|
+
try:
|
|
146
|
+
data = json.loads(text)
|
|
147
|
+
except json.JSONDecodeError:
|
|
148
|
+
start = text.find("{")
|
|
149
|
+
end = text.rfind("}") + 1
|
|
150
|
+
data = json.loads(text[start:end]) if start != -1 else {}
|
|
151
|
+
return cls(
|
|
152
|
+
sufficient=bool(data.get("sufficient", False)),
|
|
153
|
+
gaps=list(data.get("gaps", []) or []),
|
|
154
|
+
note=str(data.get("note", "")),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class Commander:
|
|
159
|
+
"""Plans and drives the swarm for a GOAL.
|
|
160
|
+
|
|
161
|
+
The Commander uses a shared runner for planning/research; each worker gets a
|
|
162
|
+
dedicated runner instance from the engine pool so concurrent workers run
|
|
163
|
+
their inference in parallel (the GIL is released during llama.cpp generation).
|
|
164
|
+
A ``provider`` may be injected (tests / non-embedded backends) to override the
|
|
165
|
+
embedded commander runner.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(self, engine: Engine, supervisor: Supervisor,
|
|
169
|
+
tools: ToolRegistry, workspace: Path,
|
|
170
|
+
provider: "LocalRunner | None" = None) -> None:
|
|
171
|
+
self.engine = engine
|
|
172
|
+
self.provider = provider or engine.runner("commander")
|
|
173
|
+
self.supervisor = supervisor
|
|
174
|
+
self.tools = tools
|
|
175
|
+
self.workspace = workspace
|
|
176
|
+
# Knowledge base loads skills + tasks + docs + tool catalog into workers.
|
|
177
|
+
self.knowledge = KnowledgeBase(workspace, tools)
|
|
178
|
+
|
|
179
|
+
async def plan(self, goal: str) -> Plan:
|
|
180
|
+
msg = await self.provider.acomplete(
|
|
181
|
+
[ChatMessage(role="user", content=f"{PLAN_PROMPT}\n\nGOAL: {goal}")],
|
|
182
|
+
json_mode=True)
|
|
183
|
+
return Plan.from_json(msg.content or "{}")
|
|
184
|
+
|
|
185
|
+
# --- Standard pipeline (backward compatible) ---------------------------
|
|
186
|
+
|
|
187
|
+
async def execute(self, goal: str) -> str:
|
|
188
|
+
"""Standard pipeline: plan -> research -> workers (no scaffold/review)."""
|
|
189
|
+
plan = await self.plan(goal)
|
|
190
|
+
save_note(self.workspace, "goal_plan",
|
|
191
|
+
json.dumps({"goal": goal, "phases": [p.__dict__ for p in plan.phases]},
|
|
192
|
+
indent=2), tags=["plan", "commander"])
|
|
193
|
+
report: list[str] = [f"GOAL: {goal}", f"Phases: {len(plan.phases)}"]
|
|
194
|
+
for idx, phase in enumerate(plan.phases, 1):
|
|
195
|
+
report.append(f"\n=== Phase {idx}: {phase.name} ===")
|
|
196
|
+
research_jobs = {f"research-{i}": self._research_job(i, t)
|
|
197
|
+
for i, t in enumerate(phase.research)}
|
|
198
|
+
if research_jobs:
|
|
199
|
+
for k, v in (await self.supervisor.run_all(research_jobs)).items():
|
|
200
|
+
report.append(f" [{k}] {v[:300]}")
|
|
201
|
+
worker_jobs = {f"worker-{i}": self._worker_job(i, t)
|
|
202
|
+
for i, t in enumerate(phase.workers)}
|
|
203
|
+
if worker_jobs:
|
|
204
|
+
for k, v in (await self.supervisor.run_all(worker_jobs)).items():
|
|
205
|
+
report.append(f" [{k}] {v[:300]}")
|
|
206
|
+
save_note(self.workspace, "goal_report", "\n".join(report), tags=["report"])
|
|
207
|
+
return "\n".join(report)
|
|
208
|
+
|
|
209
|
+
# --- Advanced pipeline (Phase 6, hardened) -----------------------------
|
|
210
|
+
|
|
211
|
+
async def execute_advanced(self, goal: str, max_research_loops: int = 3,
|
|
212
|
+
max_test_loops: int = 5,
|
|
213
|
+
verify_cmd: str | None = None,
|
|
214
|
+
live_verify: bool = False) -> str:
|
|
215
|
+
"""Fully-featured autonomous loop for a GOAL.
|
|
216
|
+
|
|
217
|
+
Per phase:
|
|
218
|
+
1. scaffold a stable working dir;
|
|
219
|
+
2. RESEARCH LOOP - run the research batch, then the Commander assesses
|
|
220
|
+
sufficiency and dispatches follow-up research tasks until findings
|
|
221
|
+
are accurate (or the loop cap is hit). Findings flow into workers.
|
|
222
|
+
3. implement workers (grounded in research + skills + guardrails);
|
|
223
|
+
4. TEST+DEBUG LOOP - once a testable state is reached, run the verify
|
|
224
|
+
command and let workers fix failures until it passes (or cap hit);
|
|
225
|
+
5. commander review gate (incl. off-track check);
|
|
226
|
+
6. optional live-verify via the desktop tool for UI goals.
|
|
227
|
+
|
|
228
|
+
The outer loop in ``run_automatic`` re-runs the whole pipeline with the
|
|
229
|
+
failing report as fix context until the GOAL is 100% met.
|
|
230
|
+
"""
|
|
231
|
+
plan = await self.plan(goal)
|
|
232
|
+
save_note(self.workspace, "goal_plan",
|
|
233
|
+
json.dumps({"goal": goal, "phases": [p.__dict__ for p in plan.phases]},
|
|
234
|
+
indent=2), tags=["plan", "commander"])
|
|
235
|
+
# Also persist a templated markdown plan for human readability.
|
|
236
|
+
from ...modules.templates import template_for
|
|
237
|
+
from ...modules.tools.instructions import _now
|
|
238
|
+
|
|
239
|
+
plan_md = template_for("plan").render(
|
|
240
|
+
goal=goal, created=_now(),
|
|
241
|
+
phases=str(len(plan.phases)),
|
|
242
|
+
description="\n".join(f"- {p.name}" for p in plan.phases),
|
|
243
|
+
)
|
|
244
|
+
save_note(self.workspace, "goal_plan_md", plan_md, tags=["plan", "commander"])
|
|
245
|
+
log.info("planned goal=%r phases=%d", goal, len(plan.phases))
|
|
246
|
+
# Load skills + tasks + docs + tool catalog into every worker instance.
|
|
247
|
+
knowledge = self.knowledge.worker_context()
|
|
248
|
+
report: list[str] = [f"GOAL: {goal}", f"Phases: {len(plan.phases)}"]
|
|
249
|
+
for idx, phase in enumerate(plan.phases, 1):
|
|
250
|
+
report.append(f"\n=== Phase {idx}: {phase.name} ===")
|
|
251
|
+
scaffold_root = self._scaffold(phase, idx)
|
|
252
|
+
# 2. RESEARCH LOOP: refine until accurate before any worker runs.
|
|
253
|
+
research_ctx = await self._research_loop(
|
|
254
|
+
goal, phase, idx, report, knowledge, max_loops=max_research_loops)
|
|
255
|
+
# 3. Implement workers, grounded in research + knowledge + guardrails.
|
|
256
|
+
context = self._build_context(goal, phase, research_ctx, scaffold_root, knowledge)
|
|
257
|
+
worker_jobs = {f"worker-{i}": self._worker_job(i, t, context)
|
|
258
|
+
for i, t in enumerate(phase.workers)}
|
|
259
|
+
if worker_jobs:
|
|
260
|
+
for k, v in (await self.supervisor.run_all(worker_jobs)).items():
|
|
261
|
+
report.append(f" [{k}] {v[:300]}")
|
|
262
|
+
# 4. TEST+DEBUG LOOP: reach a testable state, then fix until green.
|
|
263
|
+
if verify_cmd:
|
|
264
|
+
test_report = await self._test_debug_loop(
|
|
265
|
+
goal, phase, context, verify_cmd, max_loops=max_test_loops)
|
|
266
|
+
report.append(test_report)
|
|
267
|
+
# 5. Commander review gate (evaluator-optimizer + off-track check).
|
|
268
|
+
review = await self.review_goal(goal, "\n".join(report), phase=phase.name)
|
|
269
|
+
report.append(f" [review:{phase.name}] passed={review.passed} "
|
|
270
|
+
f"off_track={review.off_track} {review.feedback[:200]}")
|
|
271
|
+
if review.missing:
|
|
272
|
+
report.append(f" [review:{phase.name}] missing: {', '.join(review.missing)}")
|
|
273
|
+
# 6. Optional live-verify for UI/UX goals.
|
|
274
|
+
if live_verify:
|
|
275
|
+
lv = await self._live_verify(goal)
|
|
276
|
+
report.append(f" [live-verify:{phase.name}] "
|
|
277
|
+
f"{'PASS' if lv.ok else 'FAIL'}: {lv.summary}")
|
|
278
|
+
# 7. Self-improvement: distill what worked into skills/tasks/docs/memory
|
|
279
|
+
# so the NEXT run (and next phase) starts smarter.
|
|
280
|
+
distill = await self.distill_lessons(goal, "\n".join(report))
|
|
281
|
+
report.append(f"\n[self-improve] {distill}")
|
|
282
|
+
log.info("advanced pipeline complete goal=%r self_improve=%s", goal, distill)
|
|
283
|
+
full_report = "\n".join(report)
|
|
284
|
+
save_note(self.workspace, "goal_report", full_report, tags=["report"])
|
|
285
|
+
# Also persist a templated markdown report for human readability.
|
|
286
|
+
report_md = template_for("report").render(
|
|
287
|
+
goal=goal, created=_now(), status="complete",
|
|
288
|
+
description=full_report[:4000],
|
|
289
|
+
)
|
|
290
|
+
save_note(self.workspace, "goal_report_md", report_md, tags=["report"])
|
|
291
|
+
return full_report
|
|
292
|
+
|
|
293
|
+
async def _research_loop(self, goal: str, phase: Phase, idx: int,
|
|
294
|
+
report: list[str], knowledge: str,
|
|
295
|
+
max_loops: int = 3) -> str:
|
|
296
|
+
"""Loop research batches until the Commander judges findings sufficient.
|
|
297
|
+
|
|
298
|
+
Returns the accumulated research context (flowed into workers). Each
|
|
299
|
+
iteration runs the current research tasks concurrently, appends findings
|
|
300
|
+
to memory, and asks the Commander whether more research is needed.
|
|
301
|
+
"""
|
|
302
|
+
tasks = list(phase.research)
|
|
303
|
+
ctx_parts: list[str] = []
|
|
304
|
+
for loop in range(max_loops):
|
|
305
|
+
if not tasks:
|
|
306
|
+
break
|
|
307
|
+
jobs = {f"research-{loop}-{i}": self._research_job(i, t, knowledge)
|
|
308
|
+
for i, t in enumerate(tasks)}
|
|
309
|
+
results = await self.supervisor.run_all(jobs)
|
|
310
|
+
batch = "\n\n".join(f"[{k}]\n{v}" for k, v in results.items())
|
|
311
|
+
ctx_parts.append(batch)
|
|
312
|
+
save_note(self.workspace, f"research_{idx}_{loop}", batch,
|
|
313
|
+
tags=["research", phase.name])
|
|
314
|
+
for k, v in results.items():
|
|
315
|
+
report.append(f" [{k}] {v[:200]}")
|
|
316
|
+
# Commander assesses sufficiency of everything gathered so far.
|
|
317
|
+
assessment = await self.assess_research(goal, phase.name,
|
|
318
|
+
"\n\n".join(ctx_parts))
|
|
319
|
+
if assessment.sufficient:
|
|
320
|
+
report.append(f" [research:{phase.name}] sufficient after "
|
|
321
|
+
f"{loop + 1} loop(s): {assessment.note[:120]}")
|
|
322
|
+
break
|
|
323
|
+
# Otherwise queue follow-up research tasks and loop again.
|
|
324
|
+
tasks = assessment.gaps
|
|
325
|
+
report.append(f" [research:{phase.name}] gaps -> {len(tasks)} follow-up "
|
|
326
|
+
f"task(s): {assessment.note[:120]}")
|
|
327
|
+
return "\n\n".join(ctx_parts)
|
|
328
|
+
|
|
329
|
+
async def distill_lessons(self, goal: str, report: str) -> str:
|
|
330
|
+
"""Self-improvement gate: turn the run into new/updated instructions.
|
|
331
|
+
|
|
332
|
+
The Commander is asked to produce concrete instruction artifacts (skills,
|
|
333
|
+
task playbooks, docs, memory notes) capturing what worked, what to avoid,
|
|
334
|
+
and any gotchas - then writes them via the instructions tool so future
|
|
335
|
+
runs load them into every worker. Returns a short summary.
|
|
336
|
+
"""
|
|
337
|
+
prompt = (
|
|
338
|
+
"You are the Commander performing a self-improvement pass after a run. "
|
|
339
|
+
"Distill the run into reusable instruction artifacts so future runs "
|
|
340
|
+
"start smarter. For each artifact, output ONE line:\n"
|
|
341
|
+
" SKILL|<name>|<markdown body>\n"
|
|
342
|
+
" TASK|<name>|<markdown body>\n"
|
|
343
|
+
" DOC|<name>|<markdown body>\n"
|
|
344
|
+
" MEMORY|<topic>|<tags csv>|<markdown body>\n"
|
|
345
|
+
"Each body MUST follow its required template shape (front-matter + "
|
|
346
|
+
"sections): skills need Summary/Conventions/When to use/Examples/Gotchas; "
|
|
347
|
+
"tasks need Objective/Preconditions/Steps/Verification/Rollback; docs need "
|
|
348
|
+
"Overview/Details/References; memory is a tagged note. Only emit artifacts "
|
|
349
|
+
"that are genuinely reusable (not one-off task details). Prefer updating an "
|
|
350
|
+
"existing skill/task if relevant. Output at most 6 lines. If nothing is "
|
|
351
|
+
"reusable, output NONE.\n\n"
|
|
352
|
+
f"GOAL: {goal}\n\nRUN REPORT:\n{report[:5000]}"
|
|
353
|
+
)
|
|
354
|
+
msg = await self.provider.acomplete(
|
|
355
|
+
[ChatMessage(role="user", content=prompt)], json_mode=False)
|
|
356
|
+
text = msg.content or "NONE"
|
|
357
|
+
written = self._apply_instructions(text)
|
|
358
|
+
return f"wrote {written} artifact(s)" if written else "no reusable artifacts"
|
|
359
|
+
|
|
360
|
+
def _apply_instructions(self, text: str) -> int:
|
|
361
|
+
"""Parse the Commander's distillation lines and persist them.
|
|
362
|
+
|
|
363
|
+
Each artifact is one ``KIND|name|body`` (or ``MEMORY|topic|tags|body``)
|
|
364
|
+
record. Bodies may span multiple lines until the next ``KIND|`` record,
|
|
365
|
+
so we accumulate lines and flush when the next record starts.
|
|
366
|
+
"""
|
|
367
|
+
from ...modules.tools.instructions import InstructionsTool
|
|
368
|
+
|
|
369
|
+
tool = InstructionsTool(self.workspace)
|
|
370
|
+
count = 0
|
|
371
|
+
cur_kind: str | None = None
|
|
372
|
+
cur_rest: str = ""
|
|
373
|
+
records: list[tuple[str, str]] = []
|
|
374
|
+
|
|
375
|
+
def _flush() -> None:
|
|
376
|
+
nonlocal cur_kind, cur_rest
|
|
377
|
+
if cur_kind is not None:
|
|
378
|
+
records.append((cur_kind, cur_rest.strip()))
|
|
379
|
+
cur_kind, cur_rest = None, ""
|
|
380
|
+
|
|
381
|
+
for raw in text.splitlines():
|
|
382
|
+
line = raw.strip()
|
|
383
|
+
if not line or line == "NONE":
|
|
384
|
+
continue
|
|
385
|
+
if "|" in line and line.split("|", 1)[0].strip().lower() in (
|
|
386
|
+
"skill", "task", "doc", "memory"):
|
|
387
|
+
_flush()
|
|
388
|
+
cur_kind = line.split("|", 1)[0].strip().lower()
|
|
389
|
+
cur_rest = line.split("|", 1)[1]
|
|
390
|
+
elif cur_kind is not None:
|
|
391
|
+
cur_rest += "\n" + line
|
|
392
|
+
# lines before the first record are ignored
|
|
393
|
+
_flush()
|
|
394
|
+
|
|
395
|
+
for kind, rest in records:
|
|
396
|
+
try:
|
|
397
|
+
if kind == "memory":
|
|
398
|
+
name, tags, body = rest.split("|", 2)
|
|
399
|
+
res = tool.run("save", "memory", name=name.strip(),
|
|
400
|
+
body=body.strip(),
|
|
401
|
+
tags=[t.strip() for t in tags.split(",")])
|
|
402
|
+
elif kind in ("skill", "task", "doc"):
|
|
403
|
+
name, body = rest.split("|", 1)
|
|
404
|
+
res = tool.run("save", kind, name=name.strip(), body=body.strip())
|
|
405
|
+
else:
|
|
406
|
+
continue
|
|
407
|
+
if not res.startswith("ERROR"):
|
|
408
|
+
count += 1
|
|
409
|
+
except ValueError:
|
|
410
|
+
continue
|
|
411
|
+
return count
|
|
412
|
+
|
|
413
|
+
async def _test_debug_loop(self, goal: str, phase: Phase, context: str,
|
|
414
|
+
verify_cmd: str, max_loops: int = 5) -> str:
|
|
415
|
+
"""Reach a testable state, then run tests and let workers fix failures.
|
|
416
|
+
|
|
417
|
+
Returns a short report line. Uses the same verify command as the outer
|
|
418
|
+
loop; workers are given the failing output as fix context so they iterate
|
|
419
|
+
until green (or the cap is hit).
|
|
420
|
+
"""
|
|
421
|
+
from ...modules.system.automatic import _verify
|
|
422
|
+
|
|
423
|
+
last = ""
|
|
424
|
+
for loop in range(max_loops):
|
|
425
|
+
rc, out = _verify(self, verify_cmd)
|
|
426
|
+
if rc == 0:
|
|
427
|
+
return (f" [test:{phase.name}] {verify_cmd} -> PASS (exit 0) "
|
|
428
|
+
f"after {loop} fix(es)")
|
|
429
|
+
last = (f"Tests failed (exit {rc}). Fix the issues, then re-run "
|
|
430
|
+
f"`{verify_cmd}`.\nSTDOUT/STDERR:\n{out[:3000]}")
|
|
431
|
+
# Dispatch a focused fixer worker grounded in the failure.
|
|
432
|
+
fix_ctx = (f"{context}\n\n{last}\n\n"
|
|
433
|
+
"Fix ONLY what is needed to make the tests pass. Do not "
|
|
434
|
+
"modify unrelated files. Re-run the tests yourself to confirm.")
|
|
435
|
+
fix = {f"fixer-{loop}": self._worker_job(loop, "Fix failing tests", fix_ctx)}
|
|
436
|
+
res = await self.supervisor.run_all(fix)
|
|
437
|
+
for k, v in res.items():
|
|
438
|
+
last = f" [{k}] {v[:200]}"
|
|
439
|
+
return (f" [test:{phase.name}] {verify_cmd} -> STILL FAILING after "
|
|
440
|
+
f"{max_loops} fix loops. Last: {last[:200]}")
|
|
441
|
+
|
|
442
|
+
async def review_goal(self, goal: str, report: str,
|
|
443
|
+
phase: str | None = None) -> ReviewResult:
|
|
444
|
+
"""Commander verification gate: did the work actually meet the GOAL?
|
|
445
|
+
|
|
446
|
+
Returns a structured ReviewResult (incl. off_track). This is the evaluator
|
|
447
|
+
in the evaluator-optimizer loop and is what lets the Commander reject work
|
|
448
|
+
that a worker (or even a passing test suite) claimed was done, or that
|
|
449
|
+
diverged from the assigned task.
|
|
450
|
+
"""
|
|
451
|
+
prompt = REVIEW_PROMPT.format(goal=goal, phase=phase or "all",
|
|
452
|
+
report=report[:6000])
|
|
453
|
+
msg = await self.provider.acomplete(
|
|
454
|
+
[ChatMessage(role="user", content=prompt)], json_mode=True)
|
|
455
|
+
return ReviewResult.from_json(msg.content or "{}")
|
|
456
|
+
|
|
457
|
+
async def assess_research(self, goal: str, phase: str, research: str) -> "ResearchAssessment":
|
|
458
|
+
"""Commander gate: are the research findings sufficient to implement accurately?"""
|
|
459
|
+
prompt = RESEARCH_ASSESS_PROMPT.format(goal=goal, phase=phase,
|
|
460
|
+
research=research[:6000])
|
|
461
|
+
msg = await self.provider.acomplete(
|
|
462
|
+
[ChatMessage(role="user", content=prompt)], json_mode=True)
|
|
463
|
+
return ResearchAssessment.from_json(msg.content or "{}")
|
|
464
|
+
|
|
465
|
+
async def _live_verify(self, goal: str) -> LiveVerifyResult:
|
|
466
|
+
"""Interactive validation via the desktop tool (vision + action loop)."""
|
|
467
|
+
from ...modules.system.automatic import _live_verify as _lv
|
|
468
|
+
return await _lv(self, goal)
|
|
469
|
+
|
|
470
|
+
# --- Helpers -----------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
def _scaffold(self, phase: Phase, idx: int) -> str:
|
|
473
|
+
"""Lay the foundation for a phase so workers fill in structure, not fight it.
|
|
474
|
+
|
|
475
|
+
Creates a stable working directory under .opencommand/scaffold and writes
|
|
476
|
+
a manifest from the required scaffold template. Returns the absolute path.
|
|
477
|
+
"""
|
|
478
|
+
from ...modules.templates import template_for
|
|
479
|
+
from ...modules.tools.instructions import _now
|
|
480
|
+
|
|
481
|
+
root = self.workspace / ".opencommand" / "scaffold" / f"{idx:02d}-{phase.name}"
|
|
482
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
483
|
+
tpl = template_for("scaffold")
|
|
484
|
+
manifest = tpl.render(
|
|
485
|
+
phase=phase.name, index=str(idx), created=_now(),
|
|
486
|
+
description=(f"Phase {idx}. Workers write implementation here. "
|
|
487
|
+
f"Research tasks: {len(phase.research)}. "
|
|
488
|
+
f"Worker tasks: {len(phase.workers)}."),
|
|
489
|
+
)
|
|
490
|
+
(root / "scaffold.md").write_text(manifest, encoding="utf-8")
|
|
491
|
+
return str(root)
|
|
492
|
+
|
|
493
|
+
def _build_context(self, goal: str, phase: Phase, research: str,
|
|
494
|
+
scaffold_root: str, knowledge: str) -> str:
|
|
495
|
+
"""Assemble the shared context block injected into every worker prompt.
|
|
496
|
+
|
|
497
|
+
``knowledge`` already contains the skills + tasks + docs + tool catalog
|
|
498
|
+
loaded from the KnowledgeBase, so workers start with full instruction
|
|
499
|
+
context. Research findings and guardrails are layered on top.
|
|
500
|
+
"""
|
|
501
|
+
parts = [
|
|
502
|
+
f"ORIGINAL GOAL: {goal}",
|
|
503
|
+
f"PHASE: {phase.name}",
|
|
504
|
+
f"WRITE CODE UNDER: {scaffold_root}",
|
|
505
|
+
]
|
|
506
|
+
if research:
|
|
507
|
+
parts.append(f"RESEARCH CONTEXT (ground your work in this):\n{research}")
|
|
508
|
+
if knowledge:
|
|
509
|
+
parts.append(f"INSTRUCTIONS (skills/tasks/docs/tools):\n{knowledge}")
|
|
510
|
+
parts.append(GUARDRAILS)
|
|
511
|
+
return "\n\n".join(parts)
|
|
512
|
+
|
|
513
|
+
def _research_job(self, index: int, task: str, knowledge: str = ""):
|
|
514
|
+
# Research uses a dedicated worker runner so it can run concurrently with
|
|
515
|
+
# the Commander's planning and with other research/worker jobs.
|
|
516
|
+
runner = self.engine.worker_runner("worker", index)
|
|
517
|
+
async def _run() -> str:
|
|
518
|
+
prompt = (f"Research task: {task}. Summarize findings and best options "
|
|
519
|
+
f"concisely and cite sources.\n\n{GUARDRAILS}")
|
|
520
|
+
if knowledge:
|
|
521
|
+
prompt += f"\n\nINSTRUCTIONS (skills/tasks/docs/tools):\n{knowledge}"
|
|
522
|
+
return await AgentLoop(runner, self.tools).run(prompt)
|
|
523
|
+
return _run
|
|
524
|
+
|
|
525
|
+
def _worker_job(self, index: int, task: str, context: str = ""):
|
|
526
|
+
runner = self.engine.worker_runner("worker", index)
|
|
527
|
+
async def _run() -> str:
|
|
528
|
+
prompt = task if not context else f"{context}\n\nTASK: {task}"
|
|
529
|
+
return await AgentLoop(runner, self.tools).run(prompt)
|
|
530
|
+
return _run
|