aon-agent 1.0.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.
- aon/__init__.py +2 -0
- aon/approval_gate.py +48 -0
- aon/attachments.py +132 -0
- aon/audit.py +10 -0
- aon/backends.py +42 -0
- aon/build.py +111 -0
- aon/capabilities.py +28 -0
- aon/cli.py +147 -0
- aon/composite_skills.py +30 -0
- aon/config.py +64 -0
- aon/context.py +160 -0
- aon/default_skills/creating-skills/SKILL.md +61 -0
- aon/default_skills/creating-skills/evals/evals.json +23 -0
- aon/default_skills/creating-skills/references/best-practices.md +40 -0
- aon/default_skills/creating-skills/references/descriptions.md +39 -0
- aon/default_skills/creating-skills/references/evals.md +41 -0
- aon/default_skills/creating-skills/references/scripts.md +30 -0
- aon/docs.py +426 -0
- aon/engine.py +327 -0
- aon/executor.py +38 -0
- aon/geo.py +718 -0
- aon/hub.py +98 -0
- aon/install_tools.py +80 -0
- aon/link_intake.py +233 -0
- aon/llm_client.py +94 -0
- aon/main.py +61 -0
- aon/mcp_client.py +216 -0
- aon/mcp_server.py +134 -0
- aon/mcp_tools.py +19 -0
- aon/memory.py +180 -0
- aon/memory_extract.py +148 -0
- aon/memory_semantic.py +134 -0
- aon/model_tools.py +192 -0
- aon/persistence.py +159 -0
- aon/prompts.py +62 -0
- aon/recall.py +224 -0
- aon/router.py +209 -0
- aon/skill_author.py +72 -0
- aon/skill_evals.py +245 -0
- aon/skill_import.py +207 -0
- aon/skills.py +168 -0
- aon/static/aon-loader.svg +39 -0
- aon/static/aon-logo.svg +34 -0
- aon/static/fonts/Exo2-italic-400-latin-ext.woff2 +0 -0
- aon/static/fonts/Exo2-italic-400-latin.woff2 +0 -0
- aon/static/fonts/Exo2-normal-300-700-latin-ext.woff2 +0 -0
- aon/static/fonts/Exo2-normal-300-700-latin.woff2 +0 -0
- aon/static/fonts/JetBrainsMono-normal-400-600-latin-ext.woff2 +0 -0
- aon/static/fonts/JetBrainsMono-normal-400-600-latin.woff2 +0 -0
- aon/static/fonts/OFL-exo2.txt +93 -0
- aon/static/fonts/OFL-jetbrainsmono.txt +93 -0
- aon/static/fonts/OFL-orbitron.txt +93 -0
- aon/static/fonts/OFL-rajdhani.txt +93 -0
- aon/static/fonts/Orbitron-normal-600-800-latin.woff2 +0 -0
- aon/static/fonts/Rajdhani-normal-400-latin-ext.woff2 +0 -0
- aon/static/fonts/Rajdhani-normal-400-latin.woff2 +0 -0
- aon/static/fonts/Rajdhani-normal-500-latin-ext.woff2 +0 -0
- aon/static/fonts/Rajdhani-normal-500-latin.woff2 +0 -0
- aon/static/fonts/Rajdhani-normal-600-latin-ext.woff2 +0 -0
- aon/static/fonts/Rajdhani-normal-600-latin.woff2 +0 -0
- aon/static/fonts/Rajdhani-normal-700-latin-ext.woff2 +0 -0
- aon/static/fonts/Rajdhani-normal-700-latin.woff2 +0 -0
- aon/static/index.html +1068 -0
- aon/static/lib/aon-map.css +72 -0
- aon/static/lib/aon-map.js +425 -0
- aon/static/lib/atom-one-dark.min.css +1 -0
- aon/static/lib/cyber-icons.svg +157 -0
- aon/static/lib/cybercore.LICENSE +21 -0
- aon/static/lib/cybercore.min.css +1 -0
- aon/static/lib/fonts.css +136 -0
- aon/static/lib/highlight.min.js +1213 -0
- aon/static/lib/maplibre-gl.LICENSE +116 -0
- aon/static/lib/maplibre-gl.css +1 -0
- aon/static/lib/maplibre-gl.js +59 -0
- aon/static/lib/marked.min.js +6 -0
- aon/static/lib/purify.min.js +3 -0
- aon/summary.py +95 -0
- aon/telegram.py +34 -0
- aon/tools/__init__.py +0 -0
- aon/tools/base.py +40 -0
- aon/tools/files_read.py +32 -0
- aon/tools/files_write.py +58 -0
- aon/tools/map_tool.py +30 -0
- aon/tools/paths.py +12 -0
- aon/tools/shell.py +20 -0
- aon/tools/web.py +45 -0
- aon/web.py +522 -0
- aon_agent-1.0.0.dist-info/METADATA +369 -0
- aon_agent-1.0.0.dist-info/RECORD +93 -0
- aon_agent-1.0.0.dist-info/WHEEL +5 -0
- aon_agent-1.0.0.dist-info/entry_points.txt +2 -0
- aon_agent-1.0.0.dist-info/licenses/LICENSE +21 -0
- aon_agent-1.0.0.dist-info/top_level.txt +1 -0
aon/__init__.py
ADDED
aon/approval_gate.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# GUARDRAIL MODULE -- not editable by agent tools (see config.GUARDRAIL_PATHS).
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import asyncio
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class PendingAction:
|
|
9
|
+
tool: str
|
|
10
|
+
args: dict
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Decision:
|
|
14
|
+
approved: bool
|
|
15
|
+
edited_args: dict | None = None
|
|
16
|
+
reason: str = ""
|
|
17
|
+
|
|
18
|
+
class ApprovalGate(ABC):
|
|
19
|
+
@abstractmethod
|
|
20
|
+
async def request(self, action: PendingAction) -> Decision: ...
|
|
21
|
+
|
|
22
|
+
class AutoGate(ApprovalGate):
|
|
23
|
+
def __init__(self, approve: bool = True):
|
|
24
|
+
self._approve = approve
|
|
25
|
+
async def request(self, action: PendingAction) -> Decision:
|
|
26
|
+
return Decision(approved=self._approve, reason="auto")
|
|
27
|
+
|
|
28
|
+
class QueueGate(ApprovalGate):
|
|
29
|
+
def __init__(self, on_pending=None):
|
|
30
|
+
self._next = 1
|
|
31
|
+
self._pending: dict[int, tuple[PendingAction, asyncio.Future]] = {}
|
|
32
|
+
self._on_pending = on_pending
|
|
33
|
+
async def request(self, action: PendingAction) -> Decision:
|
|
34
|
+
rid = self._next; self._next += 1
|
|
35
|
+
fut = asyncio.get_running_loop().create_future()
|
|
36
|
+
self._pending[rid] = (action, fut)
|
|
37
|
+
if self._on_pending is not None:
|
|
38
|
+
await self._on_pending(rid, action)
|
|
39
|
+
try:
|
|
40
|
+
return await fut
|
|
41
|
+
finally:
|
|
42
|
+
self._pending.pop(rid, None)
|
|
43
|
+
def pending(self) -> list[tuple[int, PendingAction]]:
|
|
44
|
+
return [(rid, pa) for rid, (pa, _f) in self._pending.items()]
|
|
45
|
+
def resolve(self, req_id: int, decision: Decision) -> None:
|
|
46
|
+
entry = self._pending.get(req_id)
|
|
47
|
+
if entry and not entry[1].done():
|
|
48
|
+
entry[1].set_result(decision)
|
aon/attachments.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Files and photos attached to a chat goal: saved into the workspace, described to the agent,
|
|
2
|
+
and (for vision models) sent to the model as images."""
|
|
3
|
+
import base64, binascii, mimetypes, re, time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
MAX_FILES = 10
|
|
9
|
+
MAX_FILE_BYTES = 20 * 1024 * 1024
|
|
10
|
+
MAX_TOTAL_BYTES = 40 * 1024 * 1024
|
|
11
|
+
INLINE_TEXT_BYTES = 32 * 1024
|
|
12
|
+
|
|
13
|
+
_IMAGE_EXT = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
14
|
+
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AttachmentError(ValueError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Attachment:
|
|
23
|
+
name: str
|
|
24
|
+
path: Path
|
|
25
|
+
mime: str
|
|
26
|
+
size: int
|
|
27
|
+
is_image: bool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _safe_name(name: str) -> str:
|
|
31
|
+
base = re.split(r"[\\/]", name or "")[-1]
|
|
32
|
+
base = re.sub(r"[^A-Za-z0-9._ -]", "_", base).strip(" .")
|
|
33
|
+
return base[:120] or "file"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _mime(name: str, given: str) -> str:
|
|
37
|
+
ext = Path(name).suffix.lower()
|
|
38
|
+
if ext in _IMAGE_EXT:
|
|
39
|
+
return _IMAGE_EXT[ext]
|
|
40
|
+
return given or mimetypes.guess_type(name)[0] or "application/octet-stream"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def save_attachments(items: list[dict], workdir) -> list[Attachment]:
|
|
44
|
+
"""Decode and validate everything first, then write; a bad item saves nothing."""
|
|
45
|
+
items = items or []
|
|
46
|
+
if len(items) > MAX_FILES:
|
|
47
|
+
raise AttachmentError(f"too many attachments ({len(items)}); the limit is {MAX_FILES}")
|
|
48
|
+
decoded, total = [], 0
|
|
49
|
+
for it in items:
|
|
50
|
+
name = _safe_name(str(it.get("name", "")))
|
|
51
|
+
try:
|
|
52
|
+
data = base64.b64decode(it.get("data") or "", validate=True)
|
|
53
|
+
except (binascii.Error, ValueError):
|
|
54
|
+
raise AttachmentError(f"attachment {name!r} is not valid base64")
|
|
55
|
+
if len(data) > MAX_FILE_BYTES:
|
|
56
|
+
raise AttachmentError(f"attachment {name!r} is larger than {MAX_FILE_BYTES // (1024 * 1024)} MB")
|
|
57
|
+
total += len(data)
|
|
58
|
+
if total > MAX_TOTAL_BYTES:
|
|
59
|
+
raise AttachmentError(f"attachments are larger than {MAX_TOTAL_BYTES // (1024 * 1024)} MB in total")
|
|
60
|
+
decoded.append((name, str(it.get("type") or ""), data))
|
|
61
|
+
out = []
|
|
62
|
+
if not decoded:
|
|
63
|
+
return out
|
|
64
|
+
up = Path(workdir) / "uploads"
|
|
65
|
+
up.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
67
|
+
for name, given, data in decoded:
|
|
68
|
+
path = up / f"{stamp}-{name}"
|
|
69
|
+
n = 1
|
|
70
|
+
while path.exists():
|
|
71
|
+
path = up / f"{stamp}-{n}-{name}"; n += 1
|
|
72
|
+
path.write_bytes(data)
|
|
73
|
+
mime = _mime(name, given)
|
|
74
|
+
out.append(Attachment(name, path, mime, len(data), mime.startswith("image/")))
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _fmt(n: int) -> str:
|
|
79
|
+
if n < 1024:
|
|
80
|
+
return f"{n} B"
|
|
81
|
+
if n < 1024 * 1024:
|
|
82
|
+
return f"{n / 1024:.1f} KB"
|
|
83
|
+
return f"{n / (1024 * 1024):.1f} MB"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _inline_text(a: Attachment) -> str | None:
|
|
87
|
+
if a.is_image or a.size > INLINE_TEXT_BYTES:
|
|
88
|
+
return None
|
|
89
|
+
raw = a.path.read_bytes()
|
|
90
|
+
if b"\x00" in raw:
|
|
91
|
+
return None
|
|
92
|
+
try:
|
|
93
|
+
return raw.decode("utf-8")
|
|
94
|
+
except UnicodeDecodeError:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def attachment_note(atts: list[Attachment], vision: bool = True) -> str:
|
|
99
|
+
"""Text appended to the goal so the agent knows what was attached and where it is."""
|
|
100
|
+
if not atts:
|
|
101
|
+
return ""
|
|
102
|
+
lines = ["[Attached files, saved in the workspace]"]
|
|
103
|
+
blocks = []
|
|
104
|
+
for a in atts:
|
|
105
|
+
desc = f"- {a.name} ({a.mime}, {_fmt(a.size)}) at {a.path}"
|
|
106
|
+
if a.is_image:
|
|
107
|
+
desc += " - image is shown to you below" if vision else \
|
|
108
|
+
" - image; the current model cannot see images, only the file path"
|
|
109
|
+
lines.append(desc)
|
|
110
|
+
text = _inline_text(a)
|
|
111
|
+
if text is not None:
|
|
112
|
+
blocks.append(f"--- {a.name} ---\n{text}\n--- end of {a.name} ---")
|
|
113
|
+
return "\n".join(lines + blocks)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def image_parts(atts: list[Attachment]) -> list[dict]:
|
|
117
|
+
"""OpenAI-style image content parts (Ollama's /v1 endpoint accepts data URLs)."""
|
|
118
|
+
return [{"type": "image_url",
|
|
119
|
+
"image_url": {"url": f"data:{a.mime};base64," + base64.b64encode(a.path.read_bytes()).decode()}}
|
|
120
|
+
for a in atts if a.is_image]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def supports_vision(base_url: str, model: str) -> bool:
|
|
124
|
+
root = re.sub(r"/v1/?$", "", base_url.rstrip("/"))
|
|
125
|
+
try:
|
|
126
|
+
async with httpx.AsyncClient(timeout=15) as c:
|
|
127
|
+
r = await c.post(f"{root}/api/show", json={"model": model})
|
|
128
|
+
if r.status_code != 200:
|
|
129
|
+
return False
|
|
130
|
+
return "vision" in (r.json().get("capabilities") or [])
|
|
131
|
+
except (httpx.HTTPError, ValueError):
|
|
132
|
+
return False
|
aon/audit.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from aon.persistence import Database
|
|
2
|
+
|
|
3
|
+
def make_audit_hook(db: Database, session_id: int, decisions: dict):
|
|
4
|
+
async def hook(event: dict):
|
|
5
|
+
if event.get("type") != "step":
|
|
6
|
+
return
|
|
7
|
+
db.add_action(session_id, event.get("tool", ""), "",
|
|
8
|
+
"approved" if event.get("approved") else "rejected",
|
|
9
|
+
"", event.get("output", ""), "", 0)
|
|
10
|
+
return hook
|
aon/backends.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Which backend serves a chat request.
|
|
2
|
+
|
|
3
|
+
AON can keep one chat model on a separate backend (vLLM, for real multi-user serving) while
|
|
4
|
+
everything else - the embedder and the model-management tools - stays on Ollama. The chat model
|
|
5
|
+
is named by cfg.chat_model; requests for it (or for the default when it is the chat model) go to
|
|
6
|
+
cfg.chat_base_url with the chat sampling settings. Any other model stays on Ollama. With
|
|
7
|
+
cfg.chat_model blank, nothing changes: all chat is on Ollama as before.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def model_is_chat(cfg, model) -> bool:
|
|
13
|
+
"""True when this request should go to the separate chat backend."""
|
|
14
|
+
if not cfg.chat_model:
|
|
15
|
+
return False
|
|
16
|
+
return (model or cfg.default_model) == cfg.chat_model
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def chat_base_url(cfg, model=None) -> str:
|
|
20
|
+
if model_is_chat(cfg, model):
|
|
21
|
+
return cfg.chat_base_url or cfg.ollama_base_url
|
|
22
|
+
return cfg.ollama_base_url
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def chat_sampling(cfg, model=None) -> dict:
|
|
26
|
+
if not model_is_chat(cfg, model):
|
|
27
|
+
return {}
|
|
28
|
+
out = {}
|
|
29
|
+
if cfg.chat_temperature is not None:
|
|
30
|
+
out["temperature"] = cfg.chat_temperature
|
|
31
|
+
if cfg.chat_repetition_penalty is not None:
|
|
32
|
+
out["repetition_penalty"] = cfg.chat_repetition_penalty
|
|
33
|
+
if cfg.chat_top_p is not None:
|
|
34
|
+
out["top_p"] = cfg.chat_top_p
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def make_chat_llm(cfg, model, http=None):
|
|
39
|
+
from aon.llm_client import LLMClient
|
|
40
|
+
return LLMClient(chat_base_url(cfg, model), model or cfg.default_model,
|
|
41
|
+
http=http, sampling=chat_sampling(cfg, model),
|
|
42
|
+
retry_without_thinking=model_is_chat(cfg, model) and bool(cfg.chat_base_url))
|
aon/build.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from aon.config import Config
|
|
3
|
+
from aon.llm_client import LLMClient
|
|
4
|
+
from aon.tools.base import ToolRegistry
|
|
5
|
+
from aon.tools.files_read import ReadFileTool, ListDirTool
|
|
6
|
+
from aon.tools.files_write import WriteFileTool, EditFileTool
|
|
7
|
+
from aon.tools.shell import RunCommandTool
|
|
8
|
+
from aon.tools.web import WebFetchTool, WebSearchTool
|
|
9
|
+
from aon.approval_gate import ApprovalGate, AutoGate
|
|
10
|
+
from aon.engine import Agent
|
|
11
|
+
from aon.memory import MemoryStore, RememberTool
|
|
12
|
+
from aon.skills import SkillStore, ReadSkillTool, WriteSkillTool, SkillFilesTool
|
|
13
|
+
from aon.prompts import load_system_prompt
|
|
14
|
+
|
|
15
|
+
def mem_store(cfg: Config) -> MemoryStore:
|
|
16
|
+
return MemoryStore(Path("memory") / "episodes.db")
|
|
17
|
+
|
|
18
|
+
def skill_store(cfg: Config) -> SkillStore:
|
|
19
|
+
return SkillStore(Path("skills"))
|
|
20
|
+
|
|
21
|
+
def build_registry(cfg: Config, *, workdir=None, skills_root=None, mem_path=None, include_evals=True, mcp_manager=None, model=None, embedder=None) -> ToolRegistry:
|
|
22
|
+
wd = workdir or cfg.workdir
|
|
23
|
+
sk = Path(skills_root) if skills_root else Path("skills")
|
|
24
|
+
mp = Path(mem_path) if mem_path else (Path("memory") / "episodes.db")
|
|
25
|
+
r = ToolRegistry()
|
|
26
|
+
r.register(ReadFileTool(wd))
|
|
27
|
+
r.register(ListDirTool(wd))
|
|
28
|
+
r.register(WriteFileTool(wd))
|
|
29
|
+
r.register(EditFileTool(wd))
|
|
30
|
+
r.register(RunCommandTool(wd, cfg.command_timeout))
|
|
31
|
+
r.register(WebFetchTool())
|
|
32
|
+
r.register(WebSearchTool())
|
|
33
|
+
from aon.tools.map_tool import ShowMapTool
|
|
34
|
+
from aon.geo import default_geo
|
|
35
|
+
r.register(ShowMapTool(default_geo()))
|
|
36
|
+
ms = MemoryStore(mp); ss = SkillStore(sk)
|
|
37
|
+
r.register(RememberTool(ms))
|
|
38
|
+
from aon.memory_extract import SearchMemoryTool, LearnUrlTool
|
|
39
|
+
r.register(SearchMemoryTool(ms, embedder=embedder))
|
|
40
|
+
async def _fetch(url):
|
|
41
|
+
res = await WebFetchTool().run({"url": url})
|
|
42
|
+
return res.output if res.ok else ""
|
|
43
|
+
# tools that call the model themselves use the one picked for this chat
|
|
44
|
+
from aon.backends import make_chat_llm
|
|
45
|
+
tool_llm = make_chat_llm(cfg, model)
|
|
46
|
+
r.register(LearnUrlTool(_fetch, tool_llm, ms))
|
|
47
|
+
from aon.skill_author import DraftSkillTool
|
|
48
|
+
r.register(DraftSkillTool(_fetch, tool_llm, ss))
|
|
49
|
+
r.register(ReadSkillTool(ss))
|
|
50
|
+
r.register(WriteSkillTool(ss))
|
|
51
|
+
r.register(SkillFilesTool(ss))
|
|
52
|
+
from aon.capabilities import CapabilitiesTool
|
|
53
|
+
r.register(CapabilitiesTool(r, ss))
|
|
54
|
+
if include_evals:
|
|
55
|
+
from aon.skill_evals import RunSkillEvalsTool
|
|
56
|
+
r.register(RunSkillEvalsTool(cfg))
|
|
57
|
+
if mcp_manager is not None:
|
|
58
|
+
# interactive app only (not the eval sandbox): gated tools that extend AON
|
|
59
|
+
from aon.install_tools import AddMcpServerTool, ImportSkillsTool
|
|
60
|
+
r.register(AddMcpServerTool(mcp_manager))
|
|
61
|
+
r.register(ImportSkillsTool(ss))
|
|
62
|
+
from aon.model_tools import PullModelTool, ListModelsTool, RemoveModelTool
|
|
63
|
+
r.register(PullModelTool(cfg.ollama_base_url))
|
|
64
|
+
r.register(ListModelsTool(cfg.ollama_base_url))
|
|
65
|
+
r.register(RemoveModelTool(cfg.ollama_base_url))
|
|
66
|
+
from aon.mcp_tools import McpTool
|
|
67
|
+
for spec in mcp_manager.tools():
|
|
68
|
+
r.register(McpTool(mcp_manager, spec))
|
|
69
|
+
return r
|
|
70
|
+
|
|
71
|
+
def make_router(cfg, llm, reg, memory=None):
|
|
72
|
+
from aon.router import deliberate
|
|
73
|
+
tool_names = {t.name for t in reg.list()}
|
|
74
|
+
tsum = "\n".join(f"- {t.name}: {((t.description or '').splitlines() or [''])[0][:100]}"
|
|
75
|
+
for t in reg.list())
|
|
76
|
+
async def _router(goal, conversation=None):
|
|
77
|
+
from aon.router import ensure_map, pending_place_question, Plan
|
|
78
|
+
from aon.geo import map_hint
|
|
79
|
+
map_open = any("[map shown]" in (t.get("content") or "") for t in (conversation or [])[-6:])
|
|
80
|
+
pending = pending_place_question(conversation)
|
|
81
|
+
hinted = pending or map_hint(goal, map_open)
|
|
82
|
+
if hinted:
|
|
83
|
+
# map-like message: the narrow extractor (~2 s) first; if it finds a map request the big
|
|
84
|
+
# planner (~9 s, and it invented "Melbourne" places in testing) is not needed at all
|
|
85
|
+
quick = await ensure_map(Plan("", "map", [], False, None, None), goal, llm, conversation, map_open,
|
|
86
|
+
pending_question=pending)
|
|
87
|
+
if quick.map:
|
|
88
|
+
return quick
|
|
89
|
+
ssum = "\n".join(f"- {n}: {d}" for n, d in skill_store(cfg).list())
|
|
90
|
+
plan = await deliberate(goal, llm, tools_summary=tsum, skills_summary=ssum,
|
|
91
|
+
memory_text=(memory or mem_store(cfg)).context_for(goal), valid_tools=tool_names,
|
|
92
|
+
conversation=conversation)
|
|
93
|
+
# the extractor already ran (and found nothing) for hinted messages: don't ask it twice
|
|
94
|
+
return await ensure_map(plan, goal, llm, conversation, map_open, allow_extract=not hinted)
|
|
95
|
+
return _router
|
|
96
|
+
|
|
97
|
+
def build_agent(cfg: Config, gate: ApprovalGate) -> Agent:
|
|
98
|
+
reg = build_registry(cfg)
|
|
99
|
+
llm = LLMClient(cfg.ollama_base_url, cfg.default_model)
|
|
100
|
+
return Agent(llm, reg, gate, max_steps=cfg.max_steps,
|
|
101
|
+
memory=mem_store(cfg), skills=skill_store(cfg),
|
|
102
|
+
system_prompt=load_system_prompt(Path("system_prompt.txt")),
|
|
103
|
+
router=make_router(cfg, llm, reg))
|
|
104
|
+
|
|
105
|
+
def build_eval_agent(cfg, workspace, skills_dir, model, llm) -> Agent:
|
|
106
|
+
reg = build_registry(cfg, workdir=workspace, skills_root=skills_dir,
|
|
107
|
+
mem_path=Path(workspace) / "_eval_mem.md", include_evals=False)
|
|
108
|
+
return Agent(llm, reg, AutoGate(approve=True), max_steps=cfg.max_steps,
|
|
109
|
+
memory=MemoryStore(Path(workspace) / "_eval_mem.md"),
|
|
110
|
+
skills=SkillStore(skills_dir),
|
|
111
|
+
system_prompt=load_system_prompt(Path("system_prompt.txt")))
|
aon/capabilities.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from aon.tools.base import Tool, ToolResult
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CapabilitiesTool(Tool):
|
|
6
|
+
name = "capabilities"
|
|
7
|
+
description = "List what AON can do: its tools and its known skills (with any prerequisites)."
|
|
8
|
+
parameters = {"type": "object", "properties": {}}
|
|
9
|
+
mutating = False
|
|
10
|
+
|
|
11
|
+
def __init__(self, registry, skill_store):
|
|
12
|
+
self.registry = registry
|
|
13
|
+
self.skills = skill_store
|
|
14
|
+
|
|
15
|
+
async def run(self, args):
|
|
16
|
+
tools = "\n".join(
|
|
17
|
+
f"- {t.name}: {((t.description or '').splitlines() or [''])[0][:100]}"
|
|
18
|
+
for t in self.registry.list() if t.name != self.name)
|
|
19
|
+
sk = []
|
|
20
|
+
for n, d in self.skills.list():
|
|
21
|
+
try:
|
|
22
|
+
req = self.skills.info(n).get("requires", "")
|
|
23
|
+
except Exception:
|
|
24
|
+
req = ""
|
|
25
|
+
sk.append(f"- {n}: {d}" + (f" (requires: {req})" if req else ""))
|
|
26
|
+
return ToolResult(ok=True, output=(
|
|
27
|
+
"My capabilities (tools):\n" + tools +
|
|
28
|
+
"\n\nMy skills:\n" + ("\n".join(sk) if sk else "(none yet)")))
|
aon/cli.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""The `aon` command.
|
|
2
|
+
|
|
3
|
+
aon init create a home folder with a starter config.toml
|
|
4
|
+
aon serve run the web UI
|
|
5
|
+
aon mcp run AON as an MCP server over stdio (for Claude Desktop, Claude Code, Cursor, ...)
|
|
6
|
+
|
|
7
|
+
Everything AON keeps (config.toml, chats, memory, documents, skills) lives in one home folder:
|
|
8
|
+
--home, else $AON_HOME, else ~/.aon. AON runs inside that folder, so an existing install such as
|
|
9
|
+
~/app on a server is a valid home as it is.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from aon import __version__
|
|
18
|
+
|
|
19
|
+
DEFAULT_HOME = "~/.aon"
|
|
20
|
+
CONFIG = "config.toml"
|
|
21
|
+
|
|
22
|
+
CONFIG_TEMPLATE = """\
|
|
23
|
+
# AON configuration: https://github.com/leo007-htun/AON#%EF%B8%8F-configure-configtoml
|
|
24
|
+
ollama_base_url = "{ollama}" # Ollama's OpenAI-compatible endpoint (chat + embeddings)
|
|
25
|
+
default_model = "{model}" # a chat model with tool support
|
|
26
|
+
workdir = "{workdir}"
|
|
27
|
+
bind = "{bind}" # address:port of the web UI
|
|
28
|
+
always_ask = []
|
|
29
|
+
max_steps = 40
|
|
30
|
+
command_timeout = 120
|
|
31
|
+
embed_model = "nomic-embed-text"
|
|
32
|
+
context_window = 16384 # the chat model's context length
|
|
33
|
+
reply_reserve = 6144
|
|
34
|
+
history_tokens = 5000
|
|
35
|
+
|
|
36
|
+
# Optional: chat on a vLLM server for several users at once
|
|
37
|
+
# chat_base_url = "http://gpu-box:8001/v1"
|
|
38
|
+
# chat_model = "aon"
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_home(arg: str | None) -> Path:
|
|
43
|
+
return Path(arg or os.environ.get("AON_HOME") or DEFAULT_HOME).expanduser().resolve()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def init_home(home: Path, ollama: str, model: str, bind: str, force: bool = False) -> list[str]:
|
|
47
|
+
"""Create the home folder; existing files are kept unless force. Returns what was written."""
|
|
48
|
+
home = Path(home)
|
|
49
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
created = []
|
|
51
|
+
for d in ("workspace", "memory", "skills"):
|
|
52
|
+
(home / d).mkdir(exist_ok=True)
|
|
53
|
+
cfg = home / CONFIG
|
|
54
|
+
if force or not cfg.exists():
|
|
55
|
+
cfg.write_text(CONFIG_TEMPLATE.format(ollama=ollama, model=model, bind=bind,
|
|
56
|
+
workdir=(home / "workspace").as_posix()), encoding="utf-8")
|
|
57
|
+
created.append(CONFIG)
|
|
58
|
+
if not (home / "mcp.json").exists():
|
|
59
|
+
(home / "mcp.json").write_text("[]\n", encoding="utf-8")
|
|
60
|
+
created.append("mcp.json")
|
|
61
|
+
bundled = Path(__file__).resolve().parent / "default_skills"
|
|
62
|
+
for skill in sorted(bundled.iterdir()) if bundled.is_dir() else []:
|
|
63
|
+
target = home / "skills" / skill.name
|
|
64
|
+
if skill.is_dir() and not target.exists():
|
|
65
|
+
shutil.copytree(skill, target)
|
|
66
|
+
created.append(f"skills/{skill.name}")
|
|
67
|
+
return created
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _enter(home: Path) -> bool:
|
|
71
|
+
if not (home / CONFIG).exists():
|
|
72
|
+
print(f"No {CONFIG} in {home}. Create one first:\n aon init --home {home}", file=sys.stderr)
|
|
73
|
+
return False
|
|
74
|
+
os.chdir(home) # AON's data paths (memory/, skills/, mcp.json, ...) are relative to home
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cmd_init(a) -> int:
|
|
79
|
+
home = resolve_home(a.home)
|
|
80
|
+
created = init_home(home, a.ollama, a.model, a.bind, force=a.force)
|
|
81
|
+
print(f"AON home: {home}")
|
|
82
|
+
for f in created:
|
|
83
|
+
print(f" created {f}")
|
|
84
|
+
if CONFIG not in created:
|
|
85
|
+
print(f" kept the existing {CONFIG} (use --force to replace it)")
|
|
86
|
+
print("\nNext:\n"
|
|
87
|
+
f" ollama pull nomic-embed-text && ollama pull {a.model}\n"
|
|
88
|
+
f" aon serve{'' if a.home is None else ' --home ' + str(home)} # web UI on http://{a.bind}\n"
|
|
89
|
+
f" aon mcp{'' if a.home is None else ' --home ' + str(home)} # MCP server (stdio)")
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _cmd_serve(a) -> int:
|
|
94
|
+
home = resolve_home(a.home)
|
|
95
|
+
if not _enter(home):
|
|
96
|
+
return 2
|
|
97
|
+
import uvicorn
|
|
98
|
+
from aon.config import load_config
|
|
99
|
+
from aon.main import build_app_from_config
|
|
100
|
+
cfg = load_config(CONFIG)
|
|
101
|
+
host, _, port = cfg.bind.partition(":")
|
|
102
|
+
app = build_app_from_config(CONFIG)
|
|
103
|
+
# attachments arrive base64-encoded over the websocket (40 MB raw max, ~54 MB encoded)
|
|
104
|
+
uvicorn.run(app, host=a.host or host or "127.0.0.1", port=a.port or int(port or 8700),
|
|
105
|
+
ws_max_size=64 * 1024 * 1024)
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _cmd_mcp(a) -> int:
|
|
110
|
+
home = resolve_home(a.home)
|
|
111
|
+
if not _enter(home):
|
|
112
|
+
return 2
|
|
113
|
+
from aon.mcp_server import run_stdio
|
|
114
|
+
run_stdio(CONFIG)
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def main(argv=None) -> int:
|
|
119
|
+
p = argparse.ArgumentParser(prog="aon", description="AON, a self-hosted AI agent.")
|
|
120
|
+
p.add_argument("--version", action="version", version=f"aon {__version__}")
|
|
121
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
122
|
+
home_help = f"AON's home folder (default: $AON_HOME or {DEFAULT_HOME})"
|
|
123
|
+
|
|
124
|
+
i = sub.add_parser("init", help="create a home folder with a starter config")
|
|
125
|
+
i.add_argument("--home", help=home_help)
|
|
126
|
+
i.add_argument("--ollama", default="http://localhost:11434/v1", help="Ollama's /v1 URL")
|
|
127
|
+
i.add_argument("--model", default="qwen3:14b", help="chat model with tool support")
|
|
128
|
+
i.add_argument("--bind", default="127.0.0.1:8700", help="address:port for the web UI")
|
|
129
|
+
i.add_argument("--force", action="store_true", help="replace an existing config.toml")
|
|
130
|
+
i.set_defaults(fn=_cmd_init)
|
|
131
|
+
|
|
132
|
+
s = sub.add_parser("serve", help="run the web UI")
|
|
133
|
+
s.add_argument("--home", help=home_help)
|
|
134
|
+
s.add_argument("--host", help="override the host from config.toml")
|
|
135
|
+
s.add_argument("--port", type=int, help="override the port from config.toml")
|
|
136
|
+
s.set_defaults(fn=_cmd_serve)
|
|
137
|
+
|
|
138
|
+
m = sub.add_parser("mcp", help="run AON as an MCP server over stdio")
|
|
139
|
+
m.add_argument("--home", help=home_help)
|
|
140
|
+
m.set_defaults(fn=_cmd_mcp)
|
|
141
|
+
|
|
142
|
+
a = p.parse_args(argv)
|
|
143
|
+
return a.fn(a)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
sys.exit(main())
|
aon/composite_skills.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CompositeSkills:
|
|
5
|
+
def __init__(self, skill_store, mcp_manager=None):
|
|
6
|
+
self.store = skill_store
|
|
7
|
+
self.mcp = mcp_manager
|
|
8
|
+
|
|
9
|
+
def list(self):
|
|
10
|
+
out = list(self.store.list())
|
|
11
|
+
if self.mcp:
|
|
12
|
+
for p in self.mcp.prompts():
|
|
13
|
+
out.append((f"mcp:{p.server}:{p.name}", p.description))
|
|
14
|
+
return out
|
|
15
|
+
|
|
16
|
+
def items(self):
|
|
17
|
+
rows = [{"name": n, "desc": d, "source": "file"} for n, d in self.store.list()]
|
|
18
|
+
if self.mcp:
|
|
19
|
+
for p in self.mcp.prompts():
|
|
20
|
+
rows.append({"name": f"mcp:{p.server}:{p.name}", "desc": p.description, "source": "mcp"})
|
|
21
|
+
return rows
|
|
22
|
+
|
|
23
|
+
def read(self, name, path="SKILL.md"):
|
|
24
|
+
return self.store.read(name, path)
|
|
25
|
+
|
|
26
|
+
async def aread(self, name):
|
|
27
|
+
if name.startswith("mcp:"):
|
|
28
|
+
_, server, pname = name.split(":", 2)
|
|
29
|
+
return await self.mcp.get_prompt(server, pname)
|
|
30
|
+
return self.store.read(name)
|
aon/config.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import tomllib
|
|
4
|
+
|
|
5
|
+
GUARDRAIL_PATHS: tuple[str, ...] = (
|
|
6
|
+
"src/aon/executor.py",
|
|
7
|
+
"src/aon/approval_gate.py",
|
|
8
|
+
"src/aon/telegram.py",
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Config:
|
|
13
|
+
ollama_base_url: str
|
|
14
|
+
default_model: str
|
|
15
|
+
workdir: Path
|
|
16
|
+
always_ask: list[str]
|
|
17
|
+
max_steps: int
|
|
18
|
+
command_timeout: int
|
|
19
|
+
bind: str
|
|
20
|
+
telegram_bot_token: str | None = None
|
|
21
|
+
telegram_allowed_chat_ids: list[int] = field(default_factory=list)
|
|
22
|
+
# context budget: the model's window (Ollama num_ctx), room kept for thinking + the answer,
|
|
23
|
+
# and how much of a request the recent chat turns may take
|
|
24
|
+
context_window: int = 32768
|
|
25
|
+
reply_reserve: int = 6144
|
|
26
|
+
history_tokens: int = 8000
|
|
27
|
+
embed_model: str = "nomic-embed-text"
|
|
28
|
+
cross_chat_recall: bool = True # bring relevant turns from the user's other chats
|
|
29
|
+
# a separate chat backend (vLLM) for one model; blank = all chat on Ollama
|
|
30
|
+
chat_base_url: str = ""
|
|
31
|
+
chat_model: str = ""
|
|
32
|
+
chat_temperature: float | None = None
|
|
33
|
+
chat_repetition_penalty: float | None = None
|
|
34
|
+
chat_top_p: float | None = None
|
|
35
|
+
chat_vision: bool = True # the chat backend model can see images
|
|
36
|
+
|
|
37
|
+
def load_config(path: Path) -> Config:
|
|
38
|
+
path = Path(path)
|
|
39
|
+
if not path.exists():
|
|
40
|
+
raise FileNotFoundError(path)
|
|
41
|
+
data = tomllib.loads(path.read_text())
|
|
42
|
+
return Config(
|
|
43
|
+
ollama_base_url=str(data["ollama_base_url"]),
|
|
44
|
+
default_model=str(data["default_model"]),
|
|
45
|
+
workdir=Path(str(data["workdir"])).expanduser(),
|
|
46
|
+
always_ask=list(data.get("always_ask", [])),
|
|
47
|
+
max_steps=int(data.get("max_steps", 40)),
|
|
48
|
+
command_timeout=int(data.get("command_timeout", 120)),
|
|
49
|
+
bind=str(data.get("bind", "127.0.0.1:8700")),
|
|
50
|
+
telegram_bot_token=(str(data["telegram_bot_token"]) or None)
|
|
51
|
+
if data.get("telegram_bot_token") else None,
|
|
52
|
+
telegram_allowed_chat_ids=[int(x) for x in data.get("telegram_allowed_chat_ids", [])],
|
|
53
|
+
context_window=int(data.get("context_window", 32768)),
|
|
54
|
+
reply_reserve=int(data.get("reply_reserve", 6144)),
|
|
55
|
+
history_tokens=int(data.get("history_tokens", 8000)),
|
|
56
|
+
embed_model=str(data.get("embed_model", "nomic-embed-text")),
|
|
57
|
+
cross_chat_recall=bool(data.get("cross_chat_recall", True)),
|
|
58
|
+
chat_base_url=str(data.get("chat_base_url", "")),
|
|
59
|
+
chat_model=str(data.get("chat_model", "")),
|
|
60
|
+
chat_temperature=(float(data["chat_temperature"]) if data.get("chat_temperature") is not None else None),
|
|
61
|
+
chat_repetition_penalty=(float(data["chat_repetition_penalty"]) if data.get("chat_repetition_penalty") is not None else None),
|
|
62
|
+
chat_top_p=(float(data["chat_top_p"]) if data.get("chat_top_p") is not None else None),
|
|
63
|
+
chat_vision=bool(data.get("chat_vision", True)),
|
|
64
|
+
)
|