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.
Files changed (93) hide show
  1. aon/__init__.py +2 -0
  2. aon/approval_gate.py +48 -0
  3. aon/attachments.py +132 -0
  4. aon/audit.py +10 -0
  5. aon/backends.py +42 -0
  6. aon/build.py +111 -0
  7. aon/capabilities.py +28 -0
  8. aon/cli.py +147 -0
  9. aon/composite_skills.py +30 -0
  10. aon/config.py +64 -0
  11. aon/context.py +160 -0
  12. aon/default_skills/creating-skills/SKILL.md +61 -0
  13. aon/default_skills/creating-skills/evals/evals.json +23 -0
  14. aon/default_skills/creating-skills/references/best-practices.md +40 -0
  15. aon/default_skills/creating-skills/references/descriptions.md +39 -0
  16. aon/default_skills/creating-skills/references/evals.md +41 -0
  17. aon/default_skills/creating-skills/references/scripts.md +30 -0
  18. aon/docs.py +426 -0
  19. aon/engine.py +327 -0
  20. aon/executor.py +38 -0
  21. aon/geo.py +718 -0
  22. aon/hub.py +98 -0
  23. aon/install_tools.py +80 -0
  24. aon/link_intake.py +233 -0
  25. aon/llm_client.py +94 -0
  26. aon/main.py +61 -0
  27. aon/mcp_client.py +216 -0
  28. aon/mcp_server.py +134 -0
  29. aon/mcp_tools.py +19 -0
  30. aon/memory.py +180 -0
  31. aon/memory_extract.py +148 -0
  32. aon/memory_semantic.py +134 -0
  33. aon/model_tools.py +192 -0
  34. aon/persistence.py +159 -0
  35. aon/prompts.py +62 -0
  36. aon/recall.py +224 -0
  37. aon/router.py +209 -0
  38. aon/skill_author.py +72 -0
  39. aon/skill_evals.py +245 -0
  40. aon/skill_import.py +207 -0
  41. aon/skills.py +168 -0
  42. aon/static/aon-loader.svg +39 -0
  43. aon/static/aon-logo.svg +34 -0
  44. aon/static/fonts/Exo2-italic-400-latin-ext.woff2 +0 -0
  45. aon/static/fonts/Exo2-italic-400-latin.woff2 +0 -0
  46. aon/static/fonts/Exo2-normal-300-700-latin-ext.woff2 +0 -0
  47. aon/static/fonts/Exo2-normal-300-700-latin.woff2 +0 -0
  48. aon/static/fonts/JetBrainsMono-normal-400-600-latin-ext.woff2 +0 -0
  49. aon/static/fonts/JetBrainsMono-normal-400-600-latin.woff2 +0 -0
  50. aon/static/fonts/OFL-exo2.txt +93 -0
  51. aon/static/fonts/OFL-jetbrainsmono.txt +93 -0
  52. aon/static/fonts/OFL-orbitron.txt +93 -0
  53. aon/static/fonts/OFL-rajdhani.txt +93 -0
  54. aon/static/fonts/Orbitron-normal-600-800-latin.woff2 +0 -0
  55. aon/static/fonts/Rajdhani-normal-400-latin-ext.woff2 +0 -0
  56. aon/static/fonts/Rajdhani-normal-400-latin.woff2 +0 -0
  57. aon/static/fonts/Rajdhani-normal-500-latin-ext.woff2 +0 -0
  58. aon/static/fonts/Rajdhani-normal-500-latin.woff2 +0 -0
  59. aon/static/fonts/Rajdhani-normal-600-latin-ext.woff2 +0 -0
  60. aon/static/fonts/Rajdhani-normal-600-latin.woff2 +0 -0
  61. aon/static/fonts/Rajdhani-normal-700-latin-ext.woff2 +0 -0
  62. aon/static/fonts/Rajdhani-normal-700-latin.woff2 +0 -0
  63. aon/static/index.html +1068 -0
  64. aon/static/lib/aon-map.css +72 -0
  65. aon/static/lib/aon-map.js +425 -0
  66. aon/static/lib/atom-one-dark.min.css +1 -0
  67. aon/static/lib/cyber-icons.svg +157 -0
  68. aon/static/lib/cybercore.LICENSE +21 -0
  69. aon/static/lib/cybercore.min.css +1 -0
  70. aon/static/lib/fonts.css +136 -0
  71. aon/static/lib/highlight.min.js +1213 -0
  72. aon/static/lib/maplibre-gl.LICENSE +116 -0
  73. aon/static/lib/maplibre-gl.css +1 -0
  74. aon/static/lib/maplibre-gl.js +59 -0
  75. aon/static/lib/marked.min.js +6 -0
  76. aon/static/lib/purify.min.js +3 -0
  77. aon/summary.py +95 -0
  78. aon/telegram.py +34 -0
  79. aon/tools/__init__.py +0 -0
  80. aon/tools/base.py +40 -0
  81. aon/tools/files_read.py +32 -0
  82. aon/tools/files_write.py +58 -0
  83. aon/tools/map_tool.py +30 -0
  84. aon/tools/paths.py +12 -0
  85. aon/tools/shell.py +20 -0
  86. aon/tools/web.py +45 -0
  87. aon/web.py +522 -0
  88. aon_agent-1.0.0.dist-info/METADATA +369 -0
  89. aon_agent-1.0.0.dist-info/RECORD +93 -0
  90. aon_agent-1.0.0.dist-info/WHEEL +5 -0
  91. aon_agent-1.0.0.dist-info/entry_points.txt +2 -0
  92. aon_agent-1.0.0.dist-info/licenses/LICENSE +21 -0
  93. aon_agent-1.0.0.dist-info/top_level.txt +1 -0
aon/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """AON, a self-hosted AI agent."""
2
+ __version__ = "1.0.0"
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())
@@ -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
+ )