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,81 @@
|
|
|
1
|
+
"""Supervisor: dispatches and monitors worker coroutines with crash containment.
|
|
2
|
+
|
|
3
|
+
Uses asyncio.TaskGroup for structured concurrency. A failing worker is retried
|
|
4
|
+
up to ``max_retries``; a worker crash never takes down the event loop.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Awaitable, Callable
|
|
12
|
+
|
|
13
|
+
from .errors import RetryableError
|
|
14
|
+
from .logging import get_logger
|
|
15
|
+
|
|
16
|
+
log = get_logger("core.supervisor")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class WorkerHandle:
|
|
21
|
+
name: str
|
|
22
|
+
task: str
|
|
23
|
+
status: str = "pending" # pending|running|done|failed|retrying
|
|
24
|
+
attempts: int = 0
|
|
25
|
+
result: str | None = None
|
|
26
|
+
error: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Supervisor:
|
|
30
|
+
"""Runs worker coroutines with supervision and restart policy."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, max_retries: int = 3) -> None:
|
|
33
|
+
self.max_retries = max_retries
|
|
34
|
+
self.handles: dict[str, WorkerHandle] = {}
|
|
35
|
+
|
|
36
|
+
def register(self, name: str, task: str) -> WorkerHandle:
|
|
37
|
+
h = WorkerHandle(name=name, task=task)
|
|
38
|
+
self.handles[name] = h
|
|
39
|
+
return h
|
|
40
|
+
|
|
41
|
+
async def run_worker(self, name: str, coro_fn: Callable[[], Awaitable[str]]) -> str:
|
|
42
|
+
"""Run ``coro_fn`` with retry/restart containment. Returns final result."""
|
|
43
|
+
handle = self.handles.get(name) or self.register(name, name)
|
|
44
|
+
last_err: Exception | None = None
|
|
45
|
+
while handle.attempts <= self.max_retries:
|
|
46
|
+
handle.attempts += 1
|
|
47
|
+
handle.status = "running" if handle.attempts == 1 else "retrying"
|
|
48
|
+
try:
|
|
49
|
+
result = await coro_fn()
|
|
50
|
+
handle.status = "done"
|
|
51
|
+
handle.result = result
|
|
52
|
+
return result
|
|
53
|
+
except RetryableError as e:
|
|
54
|
+
last_err = e
|
|
55
|
+
await asyncio.sleep(min(2**handle.attempts, 8))
|
|
56
|
+
log.warning("worker %s retryable error attempt=%d: %s",
|
|
57
|
+
name, handle.attempts, e)
|
|
58
|
+
continue
|
|
59
|
+
except Exception as e: # noqa: BLE001 - containment
|
|
60
|
+
handle.status = "failed"
|
|
61
|
+
handle.error = f"{type(e).__name__}: {e}"
|
|
62
|
+
last_err = e
|
|
63
|
+
log.error("worker %s failed: %s", name, handle.error)
|
|
64
|
+
break
|
|
65
|
+
handle.status = "failed"
|
|
66
|
+
handle.error = str(last_err)
|
|
67
|
+
return f"FAILED after {handle.attempts} attempt(s): {last_err}"
|
|
68
|
+
|
|
69
|
+
async def run_all(self, jobs: dict[str, Callable[[], Awaitable[str]]]) -> dict[str, str]:
|
|
70
|
+
"""Run all jobs concurrently; each isolated by run_worker."""
|
|
71
|
+
for name in jobs:
|
|
72
|
+
self.register(name, name)
|
|
73
|
+
async with asyncio.TaskGroup() as tg:
|
|
74
|
+
tasks = {name: tg.create_task(self.run_worker(name, fn)) for name, fn in jobs.items()}
|
|
75
|
+
return {name: t.result() for name, t in tasks.items()}
|
|
76
|
+
|
|
77
|
+
def summary(self) -> str:
|
|
78
|
+
lines = ["Supervisor (in-process, crash-contained):"]
|
|
79
|
+
for h in self.handles.values():
|
|
80
|
+
lines.append(f" - {h.name}: {h.status} (attempts={h.attempts})")
|
|
81
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Tool registry and the BaseTool protocol used by agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from .errors import ToolError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ToolCall:
|
|
15
|
+
"""A tool invocation requested by the model."""
|
|
16
|
+
|
|
17
|
+
id: str
|
|
18
|
+
name: str
|
|
19
|
+
arguments: dict[str, Any]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ToolResult:
|
|
24
|
+
ok: bool
|
|
25
|
+
output: str
|
|
26
|
+
error: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BaseTool:
|
|
30
|
+
"""A tool an agent can call. Subclass and implement ``run``."""
|
|
31
|
+
|
|
32
|
+
name: str = "base"
|
|
33
|
+
description: str = ""
|
|
34
|
+
parameters: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
def schema(self) -> dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
"type": "function",
|
|
39
|
+
"function": {
|
|
40
|
+
"name": self.name,
|
|
41
|
+
"description": self.description,
|
|
42
|
+
"parameters": self.parameters or {"type": "object", "properties": {}},
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
def run(self, **kwargs: Any) -> str:
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
|
|
49
|
+
async def arun(self, **kwargs: Any) -> str:
|
|
50
|
+
return self.run(**kwargs)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ToolRegistry:
|
|
54
|
+
"""Holds the available tools and dispatches calls."""
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
self._tools: dict[str, BaseTool] = {}
|
|
58
|
+
|
|
59
|
+
def register(self, tool: BaseTool) -> None:
|
|
60
|
+
self._tools[tool.name] = tool
|
|
61
|
+
|
|
62
|
+
def get(self, name: str) -> BaseTool:
|
|
63
|
+
tool = self._tools.get(name)
|
|
64
|
+
if tool is None:
|
|
65
|
+
raise ToolError(f"Unknown tool: {name}")
|
|
66
|
+
return tool
|
|
67
|
+
|
|
68
|
+
def schemas(self) -> list[dict[str, Any]]:
|
|
69
|
+
return [t.schema() for t in self._tools.values()]
|
|
70
|
+
|
|
71
|
+
def dispatch(self, call: ToolCall) -> ToolResult:
|
|
72
|
+
try:
|
|
73
|
+
return ToolResult(ok=True, output=str(self.get(call.name).run(**call.arguments)))
|
|
74
|
+
except Exception as e: # noqa: BLE001 - surface as tool error
|
|
75
|
+
return ToolResult(ok=False, output="", error=f"{type(e).__name__}: {e}")
|
|
76
|
+
|
|
77
|
+
async def adispatch(self, call: ToolCall) -> ToolResult:
|
|
78
|
+
try:
|
|
79
|
+
return ToolResult(ok=True, output=str(await self.get(call.name).arun(**call.arguments)))
|
|
80
|
+
except Exception as e: # noqa: BLE001
|
|
81
|
+
return ToolResult(ok=False, output="", error=f"{type(e).__name__}: {e}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_tool_calls(message: Any) -> list[ToolCall]:
|
|
85
|
+
"""Extract tool calls from an OpenAI-compatible chat message object."""
|
|
86
|
+
calls: list[ToolCall] = []
|
|
87
|
+
raw = getattr(message, "tool_calls", None)
|
|
88
|
+
if not raw:
|
|
89
|
+
return calls
|
|
90
|
+
for tc in raw:
|
|
91
|
+
args = {}
|
|
92
|
+
if tc.function.arguments:
|
|
93
|
+
try:
|
|
94
|
+
args = json.loads(tc.function.arguments)
|
|
95
|
+
except json.JSONDecodeError:
|
|
96
|
+
args = {}
|
|
97
|
+
calls.append(ToolCall(id=tc.id, name=tc.function.name, arguments=args))
|
|
98
|
+
return calls
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def tool(
|
|
102
|
+
name: str, description: str, parameters: dict[str, Any] | None = None
|
|
103
|
+
) -> Callable[[Callable[..., str]], BaseTool]:
|
|
104
|
+
"""Decorator to turn a plain function into a registered BaseTool."""
|
|
105
|
+
|
|
106
|
+
def decorator(fn: Callable[..., str]) -> BaseTool:
|
|
107
|
+
sig = inspect.signature(fn)
|
|
108
|
+
|
|
109
|
+
class _FnTool(BaseTool):
|
|
110
|
+
@property
|
|
111
|
+
def name(self) -> str: # type: ignore[override]
|
|
112
|
+
return name
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def description(self) -> str: # type: ignore[override]
|
|
116
|
+
return description
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def parameters(self) -> dict[str, Any]: # type: ignore[override]
|
|
120
|
+
return parameters or _default_params(sig)
|
|
121
|
+
|
|
122
|
+
def run(self, **kwargs: Any) -> str:
|
|
123
|
+
return fn(**kwargs)
|
|
124
|
+
|
|
125
|
+
return _FnTool()
|
|
126
|
+
|
|
127
|
+
return decorator
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _default_params(sig: inspect.Signature) -> dict[str, Any]:
|
|
131
|
+
props: dict[str, Any] = {}
|
|
132
|
+
for pname, p in sig.parameters.items():
|
|
133
|
+
if pname in ("self", "cls"):
|
|
134
|
+
continue
|
|
135
|
+
props[pname] = {"type": "string", "description": pname}
|
|
136
|
+
return {"type": "object", "properties": props, "required": list(props)}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Embedded model engine: load and manage built-in GGUF models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .runner import CATALOG, LocalRunner
|
|
8
|
+
|
|
9
|
+
DEFAULT_MODEL_DIR = Path(__file__).resolve().parent.parent.parent.parent / "models"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Engine:
|
|
13
|
+
"""Owns the commander/worker/vision runners and lazy-loads them.
|
|
14
|
+
|
|
15
|
+
Each role has a small pool of runner instances so concurrent workers don't
|
|
16
|
+
serialize on a single model. `runner(role)` returns a shared instance for
|
|
17
|
+
sequential use (e.g. the Commander); `worker_runner(role, i)` returns a
|
|
18
|
+
dedicated instance per worker index for true parallelism.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, model_dir: Path | None = None, pool_size: int = 4) -> None:
|
|
22
|
+
self.model_dir = model_dir or DEFAULT_MODEL_DIR
|
|
23
|
+
self.pool_size = max(1, pool_size)
|
|
24
|
+
self._runners: dict[str, LocalRunner] = {}
|
|
25
|
+
self._pools: dict[str, list[LocalRunner]] = {}
|
|
26
|
+
|
|
27
|
+
def runner(self, role: str) -> LocalRunner:
|
|
28
|
+
if role not in self._runners:
|
|
29
|
+
spec = CATALOG.get(role)
|
|
30
|
+
if spec is None:
|
|
31
|
+
raise KeyError(f"No built-in model for role '{role}'")
|
|
32
|
+
self._runners[role] = LocalRunner(spec, self.model_dir / role)
|
|
33
|
+
return self._runners[role]
|
|
34
|
+
|
|
35
|
+
def worker_runner(self, role: str, index: int = 0) -> LocalRunner:
|
|
36
|
+
"""Return a dedicated runner for worker `index` (round-robin pool)."""
|
|
37
|
+
spec = CATALOG.get(role)
|
|
38
|
+
if spec is None:
|
|
39
|
+
raise KeyError(f"No built-in model for role '{role}'")
|
|
40
|
+
pool = self._pools.setdefault(role, [])
|
|
41
|
+
i = index % self.pool_size
|
|
42
|
+
while len(pool) <= i:
|
|
43
|
+
pool.append(LocalRunner(spec, self.model_dir / role))
|
|
44
|
+
return pool[i]
|
|
45
|
+
|
|
46
|
+
def ensure_downloaded(self, role: str) -> Path:
|
|
47
|
+
"""Download (if needed) and return the model path without loading weights."""
|
|
48
|
+
spec = CATALOG[role]
|
|
49
|
+
r = LocalRunner(spec, self.model_dir / role)
|
|
50
|
+
return r.download()
|
|
51
|
+
|
|
52
|
+
def download_all(self) -> dict[str, Path]:
|
|
53
|
+
return {role: self.ensure_downloaded(role) for role in CATALOG}
|
|
54
|
+
|
|
55
|
+
def status(self) -> str:
|
|
56
|
+
lines = [f"Engine model dir: {self.model_dir}"]
|
|
57
|
+
for role, spec in CATALOG.items():
|
|
58
|
+
runner = self._runners.get(role)
|
|
59
|
+
if runner is None:
|
|
60
|
+
state = "not loaded"
|
|
61
|
+
elif runner._llm is None:
|
|
62
|
+
state = "instantiated (lazy)"
|
|
63
|
+
else:
|
|
64
|
+
state = "loaded"
|
|
65
|
+
lines.append(f" - {role}: {spec.repo_id} [{spec.kind}] {state}")
|
|
66
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""OpenCommand embedded model runner.
|
|
2
|
+
|
|
3
|
+
Loads GGUF models directly with llama-cpp-python (no server, no network at
|
|
4
|
+
runtime). Supports text + native tool calling and vision (Qwen3-VL / MiniCPM-V
|
|
5
|
+
via the MTMD handler). Models live under <workspace>/models and are
|
|
6
|
+
downloaded on first use via Hugging Face Hub.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import threading
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ..core.logging import get_logger
|
|
19
|
+
from ..providers.provider import ChatMessage
|
|
20
|
+
|
|
21
|
+
log = get_logger("engine.runner")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ModelSpec:
|
|
26
|
+
"""Declarative description of a built-in model."""
|
|
27
|
+
|
|
28
|
+
key: str
|
|
29
|
+
role: str # commander | worker | vision
|
|
30
|
+
repo_id: str
|
|
31
|
+
filename: str # glob matched against repo files
|
|
32
|
+
kind: str = "text" # text | vision
|
|
33
|
+
chat_format: str | None = None # None -> auto-detect from GGUF metadata
|
|
34
|
+
n_ctx: int = 8192
|
|
35
|
+
n_gpu_layers: int = 0 # 0 = CPU; -1 = all GPU layers
|
|
36
|
+
mmproj: str | None = None # multimodal projector glob (vision)
|
|
37
|
+
description: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Catalog (verified July 2026). Commander = Ornith-1.0-9B (agentic coding, MIT).
|
|
41
|
+
# Worker = Qwen3-4B-Instruct-2507 (text, native tool calling). Vision = Qwen3-VL-4B
|
|
42
|
+
# (purpose-built GUI agent, MTMD + mmproj) as primary, with MiniCPM-V-4.6 (0.8B,
|
|
43
|
+
# ~529MB) as a lightweight CPU fallback. chat_format is None for text roles so
|
|
44
|
+
# llama.cpp auto-detects the native tool-calling template from GGUF metadata.
|
|
45
|
+
CATALOG: dict[str, ModelSpec] = {
|
|
46
|
+
"commander": ModelSpec(
|
|
47
|
+
key="commander", role="commander",
|
|
48
|
+
repo_id="deepreinforce-ai/Ornith-1.0-9B-GGUF",
|
|
49
|
+
filename="ornith-1.0-9b-Q4_K_M.gguf",
|
|
50
|
+
kind="text", n_ctx=8192,
|
|
51
|
+
description="Commander/planner model (Ornith-1.0-9B, agentic coding).",
|
|
52
|
+
),
|
|
53
|
+
"worker": ModelSpec(
|
|
54
|
+
key="worker", role="worker",
|
|
55
|
+
repo_id="unsloth/Qwen3-4B-Instruct-2507-GGUF",
|
|
56
|
+
filename="Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
|
|
57
|
+
kind="text", n_ctx=8192,
|
|
58
|
+
description="Worker execution model (Qwen3-4B-Instruct, native tool calling).",
|
|
59
|
+
),
|
|
60
|
+
"vision": ModelSpec(
|
|
61
|
+
key="vision", role="vision",
|
|
62
|
+
repo_id="unsloth/Qwen3-VL-4B-Instruct-GGUF",
|
|
63
|
+
filename="Qwen3-VL-4B-Instruct-Q4_K_M.gguf",
|
|
64
|
+
kind="vision", chat_format="mtmd", n_ctx=8192, mmproj="mmproj-F16.gguf",
|
|
65
|
+
description="Realtime vision model for screen understanding (Qwen3-VL-4B, GUI agent).",
|
|
66
|
+
),
|
|
67
|
+
"vision_small": ModelSpec(
|
|
68
|
+
key="vision_small", role="vision_small",
|
|
69
|
+
repo_id="openbmb/MiniCPM-V-4.6-gguf",
|
|
70
|
+
filename="MiniCPM-V-4_6-Q4_K_M.gguf",
|
|
71
|
+
kind="vision", chat_format="mtmd", n_ctx=4096, mmproj="mmproj-model-f16.gguf",
|
|
72
|
+
description="Lightweight vision fallback (MiniCPM-V-4.6, 0.8B, ~529MB) for CPU/low-RAM.",
|
|
73
|
+
),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class LocalRunner:
|
|
78
|
+
"""Wraps a single llama_cpp.Llama instance; thread-safe for sync calls.
|
|
79
|
+
|
|
80
|
+
Loading is lazy: the model is only mapped into RAM on first inference, so
|
|
81
|
+
ensure_downloaded can fetch weights without requiring enough RAM (or a
|
|
82
|
+
compatible CPU) to actually run them.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, spec: ModelSpec, model_dir: Path) -> None:
|
|
86
|
+
self.spec = spec
|
|
87
|
+
self.model_dir = model_dir
|
|
88
|
+
self._llm = None
|
|
89
|
+
self._lock = threading.Lock()
|
|
90
|
+
|
|
91
|
+
def download(self) -> Path:
|
|
92
|
+
"""Ensure weights exist locally; return the model path (no load)."""
|
|
93
|
+
return self._resolve_path()
|
|
94
|
+
|
|
95
|
+
def _resolve_path(self) -> Path:
|
|
96
|
+
import fnmatch
|
|
97
|
+
self.model_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
for f in self.model_dir.iterdir():
|
|
99
|
+
if fnmatch.fnmatch(f.name, self.spec.filename):
|
|
100
|
+
return f
|
|
101
|
+
return self._download()
|
|
102
|
+
|
|
103
|
+
def _download(self) -> Path:
|
|
104
|
+
import fnmatch
|
|
105
|
+
from huggingface_hub import hf_hub_download, list_repo_files
|
|
106
|
+
filename = self.spec.filename
|
|
107
|
+
# Resolve a glob to a concrete filename so HF can create a valid lock
|
|
108
|
+
# file (esp. on Windows).
|
|
109
|
+
if "*" in filename or "?" in filename:
|
|
110
|
+
repo_files = [f for f in list_repo_files(repo_id=self.spec.repo_id)
|
|
111
|
+
if f.endswith(".gguf") and f != "README.md"]
|
|
112
|
+
hits = [f for f in repo_files if fnmatch.fnmatch(f, filename)]
|
|
113
|
+
if not hits:
|
|
114
|
+
raise FileNotFoundError(f"No file matching {filename!r} in {self.spec.repo_id}")
|
|
115
|
+
filename = sorted(hits, key=len)[0]
|
|
116
|
+
path = hf_hub_download(repo_id=self.spec.repo_id, filename=filename,
|
|
117
|
+
local_dir=str(self.model_dir))
|
|
118
|
+
if self.spec.mmproj:
|
|
119
|
+
try:
|
|
120
|
+
hf_hub_download(repo_id=self.spec.repo_id, filename=self.spec.mmproj,
|
|
121
|
+
local_dir=str(self.model_dir))
|
|
122
|
+
except Exception: # noqa: BLE001 - mmproj optional
|
|
123
|
+
pass
|
|
124
|
+
return Path(path)
|
|
125
|
+
|
|
126
|
+
def _ensure_loaded(self) -> None:
|
|
127
|
+
# Guard construction with the lock so concurrent first-use from multiple
|
|
128
|
+
# worker threads can't build two Llama instances into the same slot.
|
|
129
|
+
with self._lock:
|
|
130
|
+
if self._llm is not None:
|
|
131
|
+
return
|
|
132
|
+
log.info("loading model role=%s path=%s", self.spec.role,
|
|
133
|
+
self._resolve_path().name)
|
|
134
|
+
from llama_cpp import Llama
|
|
135
|
+
kwargs: dict[str, Any] = {
|
|
136
|
+
"model_path": str(self._resolve_path()),
|
|
137
|
+
"n_ctx": self.spec.n_ctx,
|
|
138
|
+
"n_gpu_layers": self.spec.n_gpu_layers,
|
|
139
|
+
"verbose": False,
|
|
140
|
+
}
|
|
141
|
+
if self.spec.kind == "vision":
|
|
142
|
+
from llama_cpp.llama_chat_format import MTMDChatHandler
|
|
143
|
+
mmproj = next((p for p in self.model_dir.glob("*.gguf")
|
|
144
|
+
if "mmproj" in p.name.lower()), None)
|
|
145
|
+
if mmproj is None:
|
|
146
|
+
raise FileNotFoundError(f"No mmproj file found in {self.model_dir} for vision model")
|
|
147
|
+
kwargs["chat_handler"] = MTMDChatHandler(clip_model_path=str(mmproj))
|
|
148
|
+
kwargs["chat_format"] = self.spec.chat_format or "mtmd"
|
|
149
|
+
elif self.spec.chat_format:
|
|
150
|
+
kwargs["chat_format"] = self.spec.chat_format
|
|
151
|
+
self._llm = Llama(**kwargs)
|
|
152
|
+
|
|
153
|
+
def chat(self, messages: list[dict[str, Any]], tools=None, temperature=0.2,
|
|
154
|
+
max_tokens=1024, json_mode: bool = False) -> dict[str, Any]:
|
|
155
|
+
self._ensure_loaded()
|
|
156
|
+
with self._lock:
|
|
157
|
+
payload: dict[str, Any] = {
|
|
158
|
+
"messages": messages,
|
|
159
|
+
"temperature": temperature,
|
|
160
|
+
"max_tokens": max_tokens,
|
|
161
|
+
}
|
|
162
|
+
if tools:
|
|
163
|
+
payload["tools"] = tools
|
|
164
|
+
if json_mode:
|
|
165
|
+
payload["response_format"] = {"type": "json_object"}
|
|
166
|
+
resp = self._llm.create_chat_completion(**payload)
|
|
167
|
+
# Qwen3.5 emits native <tool_call> XML in the content field rather than
|
|
168
|
+
# the structured tool_calls field. Parse it so parse_tool_calls() works.
|
|
169
|
+
self._extract_native_tool_calls(resp)
|
|
170
|
+
return resp
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _extract_native_tool_calls(resp: dict[str, Any]) -> None:
|
|
174
|
+
"""Populate message.tool_calls from Qwen3.5 native XML in content.
|
|
175
|
+
|
|
176
|
+
Qwen3.5 streams tool invocations as:
|
|
177
|
+
<tool_call><function=terminal><parameter=command>
|
|
178
|
+
pwd && ls -la</parameter></function></tool_call>
|
|
179
|
+
llama.cpp does not always surface these in the structured tool_calls
|
|
180
|
+
field, so we parse the content and synthesize the OpenAI-compatible
|
|
181
|
+
structure the rest of the codebase expects.
|
|
182
|
+
"""
|
|
183
|
+
import re
|
|
184
|
+
try:
|
|
185
|
+
msg = resp["choices"][0]["message"]
|
|
186
|
+
except (KeyError, IndexError, TypeError):
|
|
187
|
+
return
|
|
188
|
+
if msg.get("tool_calls"):
|
|
189
|
+
return # already structured
|
|
190
|
+
content = msg.get("content") or ""
|
|
191
|
+
if "<tool_call>" not in content and "<function=" not in content:
|
|
192
|
+
return
|
|
193
|
+
block_re = re.compile(r"<tool_call>(.*?)</tool_call>", re.DOTALL)
|
|
194
|
+
fn_re = re.compile(r"<function=([^>]+)>")
|
|
195
|
+
param_re = re.compile(r"<parameter=([^>]+)>(.*?)</parameter>", re.DOTALL)
|
|
196
|
+
calls: list[dict[str, Any]] = []
|
|
197
|
+
# Prefer explicit <tool_call> wrappers; fall back to bare <function=...>
|
|
198
|
+
# blocks (Qwen3.5 sometimes emits them without the wrapper).
|
|
199
|
+
blocks = block_re.findall(content) or [content]
|
|
200
|
+
for block in blocks:
|
|
201
|
+
fn_match = fn_re.search(block)
|
|
202
|
+
if not fn_match:
|
|
203
|
+
continue
|
|
204
|
+
name = fn_match.group(1).strip()
|
|
205
|
+
args: dict[str, Any] = {k.strip(): v.strip() for k, v in param_re.findall(block)}
|
|
206
|
+
calls.append({
|
|
207
|
+
"id": f"call_{len(calls)}",
|
|
208
|
+
"type": "function",
|
|
209
|
+
"function": {"name": name, "arguments": json.dumps(args, ensure_ascii=False)},
|
|
210
|
+
})
|
|
211
|
+
if calls:
|
|
212
|
+
msg["tool_calls"] = calls
|
|
213
|
+
# Drop any <think>...</think> reasoning trace (e.g. Ornith) and the
|
|
214
|
+
# tool-call wrapper so the remaining content is the final answer.
|
|
215
|
+
cleaned = block_re.sub("", content)
|
|
216
|
+
cleaned = re.sub(r"<think>.*?</think>", "", cleaned, flags=re.DOTALL)
|
|
217
|
+
msg["content"] = cleaned.strip()
|
|
218
|
+
|
|
219
|
+
def complete(self, prompt: str, temperature=0.2, max_tokens=256) -> str:
|
|
220
|
+
self._ensure_loaded()
|
|
221
|
+
with self._lock:
|
|
222
|
+
resp = self._llm(prompt, temperature=temperature, max_tokens=max_tokens)
|
|
223
|
+
return resp["choices"][0]["text"]
|
|
224
|
+
|
|
225
|
+
async def acomplete(self, messages: list, **kw) -> ChatMessage:
|
|
226
|
+
"""Async wrapper so AgentLoop can call provider.acomplete unchanged."""
|
|
227
|
+
# Convert ChatMessage dataclasses to plain dicts for llama_cpp,
|
|
228
|
+
# preserving tool_calls / tool_call_id for multi-turn tool loops.
|
|
229
|
+
def _to_dict(m):
|
|
230
|
+
if isinstance(m, ChatMessage):
|
|
231
|
+
d: dict[str, Any] = {"role": m.role, "content": m.content}
|
|
232
|
+
if m.name:
|
|
233
|
+
d["name"] = m.name
|
|
234
|
+
if getattr(m, "tool_calls", None):
|
|
235
|
+
d["tool_calls"] = m.tool_calls
|
|
236
|
+
if getattr(m, "tool_call_id", None):
|
|
237
|
+
d["tool_call_id"] = m.tool_call_id
|
|
238
|
+
return d
|
|
239
|
+
return m
|
|
240
|
+
|
|
241
|
+
msgs = [_to_dict(m) for m in messages]
|
|
242
|
+
# Run the (blocking) llama_cpp inference in a worker thread so the event
|
|
243
|
+
# loop is free for other concurrent workers. The GIL is released during
|
|
244
|
+
# the C++ generation, so genuinely parallel token generation happens when
|
|
245
|
+
# each worker uses its own runner instance.
|
|
246
|
+
loop = asyncio.get_running_loop()
|
|
247
|
+
resp = await loop.run_in_executor(
|
|
248
|
+
None,
|
|
249
|
+
lambda: self.chat(
|
|
250
|
+
msgs, tools=kw.get("tools"),
|
|
251
|
+
temperature=kw.get("temperature", 0.2),
|
|
252
|
+
max_tokens=kw.get("max_tokens", 1024),
|
|
253
|
+
json_mode=kw.get("json_mode", False),
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
msg = resp["choices"][0]["message"]
|
|
257
|
+
cm = ChatMessage(role=msg.get("role", "assistant"),
|
|
258
|
+
content=msg.get("content") or "", name=msg.get("name"))
|
|
259
|
+
# llama_cpp returns tool_calls as dicts; expose as objects so
|
|
260
|
+
# parse_tool_calls() works unchanged.
|
|
261
|
+
raw_tc = msg.get("tool_calls")
|
|
262
|
+
if raw_tc:
|
|
263
|
+
|
|
264
|
+
class _Fn:
|
|
265
|
+
def __init__(self, d):
|
|
266
|
+
self.name = d["function"]["name"]
|
|
267
|
+
self.arguments = d["function"].get("arguments", "{}")
|
|
268
|
+
|
|
269
|
+
class _TC:
|
|
270
|
+
def __init__(self, d):
|
|
271
|
+
self.id = d.get("id", "call")
|
|
272
|
+
self.function = _Fn(d)
|
|
273
|
+
|
|
274
|
+
cm.tool_calls = [_TC(d) for d in raw_tc]
|
|
275
|
+
return cm
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Knowledge base: assembles the instruction context injected into workers.
|
|
2
|
+
|
|
3
|
+
Each worker instance is bootstrapped with:
|
|
4
|
+
- SKILLS (modules/skills/*.md) -> how to do kinds of tasks
|
|
5
|
+
- TASKS (<workspace>/.opencommand/tasks/*.md) -> reusable playbooks
|
|
6
|
+
- TOOLS (registry schema summaries) -> what actions are available
|
|
7
|
+
- DOCS (<workspace>/.opencommand/docs/*.md) -> learned reference docs
|
|
8
|
+
|
|
9
|
+
This is what lets the Commander "load skills, tasks, and tools into each worker
|
|
10
|
+
instance" and self-improve over time: new skills/tasks/docs written by the
|
|
11
|
+
instructions tool are picked up on the next run (or next phase).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ..core.tools import ToolRegistry
|
|
19
|
+
from .tools.instructions import builtin_skills_dir, skills_dir
|
|
20
|
+
|
|
21
|
+
_SKILLS_MAX_CHARS = 4000
|
|
22
|
+
_TASKS_MAX_CHARS = 2500
|
|
23
|
+
_DOCS_MAX_CHARS = 2500
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class KnowledgeBase:
|
|
27
|
+
"""Reads skills/tasks/docs/tools and renders a bounded context string."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, workspace: Path, tools: ToolRegistry) -> None:
|
|
30
|
+
self.workspace = workspace
|
|
31
|
+
self.tools = tools
|
|
32
|
+
|
|
33
|
+
def skills(self) -> str:
|
|
34
|
+
# Built-in skills ship with the package (read-only defaults); the swarm
|
|
35
|
+
# writes new/updated skills under the workspace, which take precedence.
|
|
36
|
+
builtin = self._read_bounded(builtin_skills_dir(), _SKILLS_MAX_CHARS, "SKILLS")
|
|
37
|
+
ws = self._read_bounded(skills_dir(self.workspace), _SKILLS_MAX_CHARS, "SKILLS")
|
|
38
|
+
parts = [p for p in (builtin, ws) if p]
|
|
39
|
+
return "\n\n".join(parts)
|
|
40
|
+
|
|
41
|
+
def tasks(self) -> str:
|
|
42
|
+
root = self.workspace / ".opencommand" / "tasks"
|
|
43
|
+
return self._read_bounded(root, _TASKS_MAX_CHARS, "TASKS")
|
|
44
|
+
|
|
45
|
+
def docs(self) -> str:
|
|
46
|
+
root = self.workspace / ".opencommand" / "docs"
|
|
47
|
+
return self._read_bounded(root, _DOCS_MAX_CHARS, "DOCS")
|
|
48
|
+
|
|
49
|
+
def tools_catalog(self) -> str:
|
|
50
|
+
lines = []
|
|
51
|
+
for t in self.tools._tools.values():
|
|
52
|
+
params = t.parameters or {}
|
|
53
|
+
props = params.get("properties", {})
|
|
54
|
+
args = ", ".join(props.keys()) or "none"
|
|
55
|
+
lines.append(f"- {t.name}({args}): {t.description}")
|
|
56
|
+
return "AVAILABLE TOOLS:\n" + "\n".join(lines)
|
|
57
|
+
|
|
58
|
+
def worker_context(self) -> str:
|
|
59
|
+
"""Full instruction block for a worker's system prompt."""
|
|
60
|
+
parts = [self.tools_catalog()]
|
|
61
|
+
if sk := self.skills():
|
|
62
|
+
parts.append(sk)
|
|
63
|
+
if tk := self.tasks():
|
|
64
|
+
parts.append(tk)
|
|
65
|
+
if dc := self.docs():
|
|
66
|
+
parts.append(dc)
|
|
67
|
+
return "\n\n".join(parts)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _read_bounded(root: Path, limit: int, label: str) -> str:
|
|
71
|
+
if not root.exists():
|
|
72
|
+
return ""
|
|
73
|
+
chunks: list[str] = []
|
|
74
|
+
total = 0
|
|
75
|
+
for f in sorted(root.glob("*.md")):
|
|
76
|
+
text = f.read_text(encoding="utf-8")
|
|
77
|
+
if total + len(text) > limit:
|
|
78
|
+
text = text[: max(0, limit - total)]
|
|
79
|
+
chunks.append(f"## {f.stem}\n{text}")
|
|
80
|
+
total += len(text)
|
|
81
|
+
if total >= limit:
|
|
82
|
+
break
|
|
83
|
+
if not chunks:
|
|
84
|
+
return ""
|
|
85
|
+
return f"{label}:\n" + "\n\n".join(chunks)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# OpenCommand Memory
|
|
2
|
+
|
|
3
|
+
Obsidian-style tagged memory store. Notes are saved per-topic as markdown files
|
|
4
|
+
under `<workspace>/.opencommand/memory/` by the `memory` tool. Use `#tags` for
|
|
5
|
+
retrieval. This file is the index.
|
|
6
|
+
|
|
7
|
+
## Index
|
|
8
|
+
- `goal_plan.md` — the Commander's multi-phase plan for the current goal.
|
|
9
|
+
- `goal_report.md` — final report after executing a goal.
|
|
10
|
+
|
|
11
|
+
## Conventions
|
|
12
|
+
- One topic per file: `topic.md` with `# Topic #tag1 #tag2` header.
|
|
13
|
+
- Workers and the Commander both read/write here; it is the shared long-term
|
|
14
|
+
memory of the swarm.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Bash Skill
|
|
2
|
+
|
|
3
|
+
Use `bash` for Linux/macOS shell tasks. OpenCommand runs shell via the
|
|
4
|
+
`terminal` tool, which on POSIX uses `/bin/sh -c`.
|
|
5
|
+
|
|
6
|
+
## Conventions
|
|
7
|
+
- Prefer portable POSIX sh; avoid bashisms unless the target is GNU/Linux.
|
|
8
|
+
- Chain with `&&` and use `set -euo pipefail` in scripts for safety.
|
|
9
|
+
- Use `uv` for Python: `uv add <pkg>`, `uv run <cmd>`, `uv sync`.
|
|
10
|
+
- Inspect processes with `ps`, `pgrep`; logs with `tail -f`.
|
|
11
|
+
- For long-running servers, background them and capture logs.
|
|
12
|
+
|
|
13
|
+
## When to use
|
|
14
|
+
- Installing system deps, running tests, git operations, build steps.
|