eljay-ai 1.1.0
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.
- package/AGENTS.md +57 -0
- package/README.md +479 -0
- package/agent/__init__.py +10 -0
- package/agent/agent.py +269 -0
- package/agent/agents/__init__.py +34 -0
- package/agent/agents/registry.py +212 -0
- package/agent/builtin_tools.py +591 -0
- package/agent/chat.py +354 -0
- package/agent/config.py +67 -0
- package/agent/context.py +63 -0
- package/agent/edits.py +167 -0
- package/agent/gitaware.py +126 -0
- package/agent/hardware.py +331 -0
- package/agent/knowledge.py +133 -0
- package/agent/memory.py +98 -0
- package/agent/ollama_client.py +133 -0
- package/agent/permissions.py +93 -0
- package/agent/providers/__init__.py +51 -0
- package/agent/providers/base.py +78 -0
- package/agent/providers/image.py +110 -0
- package/agent/providers/ollama.py +111 -0
- package/agent/providers/video.py +96 -0
- package/agent/providers/web.py +155 -0
- package/agent/router.py +200 -0
- package/agent/rules.py +52 -0
- package/agent/runner.py +168 -0
- package/agent/skills.py +161 -0
- package/agent/tools.py +102 -0
- package/agent/verification.py +80 -0
- package/agent/workspace.py +487 -0
- package/bin/eljay +5 -0
- package/bin/eljay.cmd +4 -0
- package/bin/myagent +4 -0
- package/bin/myagent.cmd +4 -0
- package/eljay-ai-1.1.0.tgz +0 -0
- package/eljay.js +56 -0
- package/eljay.py +150 -0
- package/install.ps1 +28 -0
- package/knowledge/reference/diffusers.md +32 -0
- package/knowledge/reference/video-providers.md +21 -0
- package/knowledge/setup/comfyui.md +35 -0
- package/knowledge/setup/image-providers.md +16 -0
- package/knowledge/test-category/test-entry.md +5 -0
- package/myagent.py +30 -0
- package/package.json +40 -0
- package/skills/coding/code-review.md +3 -0
- package/skills/coding/fix-attempt-protocol.md +9 -0
- package/skills/debugging/root-cause-analysis.md +9 -0
- package/skills/general/communication.md +7 -0
- package/skills/laravel/authentication.md +15 -0
- package/skills/mysql/performance.md +9 -0
- package/skills/php/standards.md +8 -0
- package/skills/react/component-best-practices.md +8 -0
- package/skills/research/source-tracking.md +7 -0
- package/skills/security/input-validation.md +9 -0
- package/skills/testing/pytest-best-practices.md +7 -0
- package/tests/run_all.py +34 -0
- package/tests/test_agent_core.py +385 -0
- package/tests/test_capabilities.py +169 -0
- package/tests/test_edits.py +117 -0
- package/tests/test_eljay.py +209 -0
- package/tests/test_runner.py +114 -0
- package/tests/test_universal.py +458 -0
- package/tests/test_workspace.py +221 -0
package/agent/agent.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Agent core: the reason -> act -> observe loop with tool calling.
|
|
2
|
+
|
|
3
|
+
The model is asked to reply either with a normal answer, or with a JSON object
|
|
4
|
+
`{"name": "<tool>", "arguments": {...}}` when it needs a tool. We support two
|
|
5
|
+
mechanisms so the agent works across models:
|
|
6
|
+
|
|
7
|
+
1. Native tool calls (`message.tool_calls`) — used by larger/capable models.
|
|
8
|
+
2. Text fallback — small models like qwen2.5-coder:3b emit the call as plain
|
|
9
|
+
JSON text in `message.content`. We parse that.
|
|
10
|
+
|
|
11
|
+
Phase 1/3 testing on this machine showed qwen2.5-coder:3b uses mechanism (2),
|
|
12
|
+
so the fallback is not optional — it is the primary path here.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .context import trim_messages
|
|
23
|
+
from .ollama_client import OllamaClient
|
|
24
|
+
from .permissions import PermissionPolicy
|
|
25
|
+
from .tools import ToolRegistry
|
|
26
|
+
|
|
27
|
+
# Matches fenced code blocks (```json ... ``` or ``` ... ```).
|
|
28
|
+
_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
|
|
29
|
+
|
|
30
|
+
# Keys models use to carry tool arguments.
|
|
31
|
+
_ARG_KEYS = ("arguments", "parameters", "args")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _first_balanced_object(text: str) -> str | None:
|
|
35
|
+
"""Return the first balanced {...} substring, ignoring braces in strings."""
|
|
36
|
+
start = text.find("{")
|
|
37
|
+
if start == -1:
|
|
38
|
+
return None
|
|
39
|
+
depth = 0
|
|
40
|
+
in_string = False
|
|
41
|
+
escaped = False
|
|
42
|
+
for i in range(start, len(text)):
|
|
43
|
+
ch = text[i]
|
|
44
|
+
if in_string:
|
|
45
|
+
if escaped:
|
|
46
|
+
escaped = False
|
|
47
|
+
elif ch == "\\":
|
|
48
|
+
escaped = True
|
|
49
|
+
elif ch == '"':
|
|
50
|
+
in_string = False
|
|
51
|
+
continue
|
|
52
|
+
if ch == '"':
|
|
53
|
+
in_string = True
|
|
54
|
+
elif ch == "{":
|
|
55
|
+
depth += 1
|
|
56
|
+
elif ch == "}":
|
|
57
|
+
depth -= 1
|
|
58
|
+
if depth == 0:
|
|
59
|
+
return text[start : i + 1]
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _json_object(text: str) -> dict[str, Any] | None:
|
|
64
|
+
try:
|
|
65
|
+
obj = json.loads(text)
|
|
66
|
+
except (json.JSONDecodeError, TypeError):
|
|
67
|
+
return None
|
|
68
|
+
return obj if isinstance(obj, dict) else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AgentCore:
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
client: OllamaClient,
|
|
75
|
+
model: str,
|
|
76
|
+
registry: ToolRegistry,
|
|
77
|
+
system_prompt: str,
|
|
78
|
+
max_steps: int = 6,
|
|
79
|
+
on_event: Callable[[str], None] | None = None,
|
|
80
|
+
policy: PermissionPolicy | None = None,
|
|
81
|
+
max_context_chars: int = 0,
|
|
82
|
+
) -> None:
|
|
83
|
+
self.client = client
|
|
84
|
+
self.model = model
|
|
85
|
+
self.registry = registry
|
|
86
|
+
self.system_prompt = system_prompt
|
|
87
|
+
self.max_steps = max_steps
|
|
88
|
+
self.on_event = on_event or (lambda _msg: None)
|
|
89
|
+
# Fail closed by default: a non-safe tool with no way to confirm is denied.
|
|
90
|
+
self.policy = policy or PermissionPolicy(on_event=self.on_event)
|
|
91
|
+
# 0 means "no trimming".
|
|
92
|
+
self.max_context_chars = max_context_chars
|
|
93
|
+
|
|
94
|
+
# -- tool call detection ----------------------------------------------
|
|
95
|
+
|
|
96
|
+
def _native_calls(self, message: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
|
|
97
|
+
calls: list[tuple[str, dict[str, Any]]] = []
|
|
98
|
+
for raw in message.get("tool_calls") or []:
|
|
99
|
+
fn = raw.get("function") or {}
|
|
100
|
+
name = fn.get("name")
|
|
101
|
+
if not isinstance(name, str) or name not in self.registry:
|
|
102
|
+
continue
|
|
103
|
+
args = fn.get("arguments")
|
|
104
|
+
if isinstance(args, str):
|
|
105
|
+
args = _json_object(args) or {}
|
|
106
|
+
if not isinstance(args, dict):
|
|
107
|
+
args = {}
|
|
108
|
+
calls.append((name, args))
|
|
109
|
+
return calls
|
|
110
|
+
|
|
111
|
+
def _text_call(self, content: str) -> list[tuple[str, dict[str, Any]]]:
|
|
112
|
+
candidates: list[str] = [content.strip()]
|
|
113
|
+
candidates += [b.strip() for b in _FENCE_RE.findall(content)]
|
|
114
|
+
balanced = _first_balanced_object(content)
|
|
115
|
+
if balanced:
|
|
116
|
+
candidates.append(balanced)
|
|
117
|
+
|
|
118
|
+
for cand in candidates:
|
|
119
|
+
if not cand:
|
|
120
|
+
continue
|
|
121
|
+
obj = _json_object(cand)
|
|
122
|
+
if not obj:
|
|
123
|
+
continue
|
|
124
|
+
name = obj.get("name")
|
|
125
|
+
if not isinstance(name, str) or name not in self.registry:
|
|
126
|
+
continue
|
|
127
|
+
args: Any = {}
|
|
128
|
+
for key in _ARG_KEYS:
|
|
129
|
+
if key in obj:
|
|
130
|
+
args = obj[key]
|
|
131
|
+
break
|
|
132
|
+
if isinstance(args, str):
|
|
133
|
+
args = _json_object(args) or {}
|
|
134
|
+
if not isinstance(args, dict):
|
|
135
|
+
args = {}
|
|
136
|
+
return [(name, args)]
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
def _detect_calls(
|
|
140
|
+
self, message: dict[str, Any], content: str
|
|
141
|
+
) -> list[tuple[str, dict[str, Any]]]:
|
|
142
|
+
return self._native_calls(message) or self._text_call(content)
|
|
143
|
+
|
|
144
|
+
def _looks_like_bad_tool_call(self, content: str) -> bool:
|
|
145
|
+
"""True if the model emitted tool-call-shaped JSON naming no real tool.
|
|
146
|
+
|
|
147
|
+
Small models sometimes emit `{"name": "None"}` or `{"name": "await"}`
|
|
148
|
+
for ordinary questions. We must not show that raw JSON to the user.
|
|
149
|
+
"""
|
|
150
|
+
if not content.strip():
|
|
151
|
+
return False
|
|
152
|
+
candidates = [content.strip()]
|
|
153
|
+
candidates += [b.strip() for b in _FENCE_RE.findall(content)]
|
|
154
|
+
balanced = _first_balanced_object(content)
|
|
155
|
+
if balanced:
|
|
156
|
+
candidates.append(balanced)
|
|
157
|
+
for cand in candidates:
|
|
158
|
+
obj = _json_object(cand)
|
|
159
|
+
if not obj:
|
|
160
|
+
continue
|
|
161
|
+
if not any(k in obj for k in ("name",) + _ARG_KEYS):
|
|
162
|
+
continue
|
|
163
|
+
name = obj.get("name")
|
|
164
|
+
if not (isinstance(name, str) and name in self.registry):
|
|
165
|
+
return True
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
def _nudge_message(self) -> str:
|
|
169
|
+
names = ", ".join(self.registry.names()) or "(none)"
|
|
170
|
+
return (
|
|
171
|
+
f"That JSON was not a valid tool call. Available tools: {names}. "
|
|
172
|
+
"Answer the user's request directly in plain language, or call one of "
|
|
173
|
+
'the available tools using {"name": "<tool_name>", "arguments": {...}}.'
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# -- execution ---------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def _handle_call(self, name: str, args: dict[str, Any]) -> str:
|
|
179
|
+
"""Run a tool, but only after it passes the permission gate."""
|
|
180
|
+
tool = self.registry.get(name)
|
|
181
|
+
if tool is None:
|
|
182
|
+
return f"ERROR: unknown tool '{name}'"
|
|
183
|
+
preview = tool.make_preview(args)
|
|
184
|
+
risk = tool.risk_for(args)
|
|
185
|
+
if not self.policy.request(tool, preview, risk):
|
|
186
|
+
return (
|
|
187
|
+
"PERMISSION DENIED: the user did not approve this operation. "
|
|
188
|
+
"Do not retry it. Explain what you intended to do, or ask the user."
|
|
189
|
+
)
|
|
190
|
+
return self._execute(name, args)
|
|
191
|
+
|
|
192
|
+
def _execute(self, name: str, args: dict[str, Any]) -> str:
|
|
193
|
+
self.on_event(f"[tool] {name}({json.dumps(args, ensure_ascii=False)})")
|
|
194
|
+
tool = self.registry.get(name)
|
|
195
|
+
if tool is None: # defensive; _detect_calls already checked
|
|
196
|
+
return f"ERROR: unknown tool '{name}'"
|
|
197
|
+
try:
|
|
198
|
+
output = tool.run(args)
|
|
199
|
+
except TypeError as exc:
|
|
200
|
+
return f"ERROR: bad arguments for '{name}': {exc}"
|
|
201
|
+
except Exception as exc: # noqa: BLE001 - surface any tool failure to the model
|
|
202
|
+
return f"ERROR: {type(exc).__name__}: {exc}"
|
|
203
|
+
if isinstance(output, str):
|
|
204
|
+
return output
|
|
205
|
+
return json.dumps(output, ensure_ascii=False, indent=2, default=str)
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _result_message(name: str, output: str) -> str:
|
|
209
|
+
# Feeding results back as a *user* turn (not the `tool` role) was verified
|
|
210
|
+
# to be reliable with qwen2.5-coder:3b on this machine.
|
|
211
|
+
if output.lstrip().startswith("ERROR"):
|
|
212
|
+
return (
|
|
213
|
+
f"TOOL RESULT ({name}):\n{output}\n\n"
|
|
214
|
+
"That call failed. Do not repeat the same call with the same "
|
|
215
|
+
"arguments. Try a different tool or different arguments "
|
|
216
|
+
"(for example use list_files or search_code to find the right "
|
|
217
|
+
"path), or explain the problem to the user."
|
|
218
|
+
)
|
|
219
|
+
return (
|
|
220
|
+
f"TOOL RESULT ({name}):\n{output}\n\n"
|
|
221
|
+
"Using this result, answer the user's question directly. "
|
|
222
|
+
"Do not call the same tool again unless it returned an error."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# -- main loop ---------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def run(self, messages: list[dict[str, Any]]) -> str:
|
|
228
|
+
"""Run one user turn to completion, mutating `messages` in place."""
|
|
229
|
+
nudged = False
|
|
230
|
+
for _step in range(1, self.max_steps + 1):
|
|
231
|
+
# Never send the whole conversation unbounded: trim to the budget.
|
|
232
|
+
payload = trim_messages(messages, self.max_context_chars)
|
|
233
|
+
response = self.client.chat_full(
|
|
234
|
+
self.model, payload, tools=self.registry.schemas() or None
|
|
235
|
+
)
|
|
236
|
+
message = response.get("message", {}) or {}
|
|
237
|
+
content = message.get("content") or ""
|
|
238
|
+
calls = self._detect_calls(message, content)
|
|
239
|
+
|
|
240
|
+
if calls:
|
|
241
|
+
# Record the model's tool request, then each result.
|
|
242
|
+
messages.append({"role": "assistant", "content": content})
|
|
243
|
+
for name, args in calls:
|
|
244
|
+
output = self._handle_call(name, args)
|
|
245
|
+
messages.append(
|
|
246
|
+
{"role": "user", "content": self._result_message(name, output)}
|
|
247
|
+
)
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
if self._looks_like_bad_tool_call(content):
|
|
251
|
+
if nudged:
|
|
252
|
+
self.on_event("[warn] model kept requesting an unknown tool")
|
|
253
|
+
return (
|
|
254
|
+
"I couldn't complete that request: the model kept trying to "
|
|
255
|
+
"call an unavailable tool. Try rephrasing, or check /tools."
|
|
256
|
+
)
|
|
257
|
+
nudged = True
|
|
258
|
+
self.on_event("[warn] unknown tool call; asking model to answer directly")
|
|
259
|
+
messages.append({"role": "assistant", "content": content})
|
|
260
|
+
messages.append({"role": "user", "content": self._nudge_message()})
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
return content.strip()
|
|
264
|
+
|
|
265
|
+
self.on_event("[warn] reached the maximum number of tool steps")
|
|
266
|
+
return (
|
|
267
|
+
"I stopped after reaching the maximum number of tool steps without "
|
|
268
|
+
"a final answer. Try rewording the request."
|
|
269
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Agent registry for ElJay AI.
|
|
2
|
+
|
|
3
|
+
Each agent is a specialized persona with its own system prompt, tool subset,
|
|
4
|
+
and provider capabilities. The Main Agent acts as a coordinator that routes
|
|
5
|
+
user requests to the appropriate specialized agent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .registry import (
|
|
9
|
+
AgentDefinition,
|
|
10
|
+
AGENTS,
|
|
11
|
+
MAIN,
|
|
12
|
+
CODING,
|
|
13
|
+
RESEARCH,
|
|
14
|
+
IMAGE,
|
|
15
|
+
VIDEO,
|
|
16
|
+
KNOWLEDGE,
|
|
17
|
+
get_agent,
|
|
18
|
+
list_agents,
|
|
19
|
+
route_request,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AgentDefinition",
|
|
24
|
+
"AGENTS",
|
|
25
|
+
"MAIN",
|
|
26
|
+
"CODING",
|
|
27
|
+
"RESEARCH",
|
|
28
|
+
"IMAGE",
|
|
29
|
+
"VIDEO",
|
|
30
|
+
"KNOWLEDGE",
|
|
31
|
+
"get_agent",
|
|
32
|
+
"list_agents",
|
|
33
|
+
"route_request",
|
|
34
|
+
]
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Agent definitions and request routing for ElJay AI.
|
|
2
|
+
|
|
3
|
+
An ``AgentDefinition`` captures everything needed to run a specialized agent:
|
|
4
|
+
its name/purpose, the tool names it can use, the providers it depends on,
|
|
5
|
+
the system-prompt additions, and a routing keyword list.
|
|
6
|
+
|
|
7
|
+
The ``route_request`` function does keyword + context-based dispatch. It is
|
|
8
|
+
deliberately simple (no LLM call) so routing is fast and deterministic.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class AgentDefinition:
|
|
20
|
+
"""Static definition of a specialized agent."""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
purpose: str
|
|
24
|
+
tools: list[str] = field(default_factory=list)
|
|
25
|
+
providers: list[str] = field(default_factory=list)
|
|
26
|
+
system_additions: str = ""
|
|
27
|
+
keywords: list[str] = field(default_factory=list)
|
|
28
|
+
priority: int = 0 # higher = more specific, wins ties
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Tool name constants — each agent gets the tools it needs.
|
|
33
|
+
# Coding: list_files, read_file, search_code, write_file, edit_file,
|
|
34
|
+
# run_command, git_status, git_diff, git_log, run_test,
|
|
35
|
+
# run_lint, run_build, project_info, detect_checks
|
|
36
|
+
# Research: web_search, fetch_web_page
|
|
37
|
+
# Image: generate_image
|
|
38
|
+
# Video: generate_video
|
|
39
|
+
# Knowledge: read_file, search_code, list_files, search_knowledge, remember
|
|
40
|
+
# Main: all (coordination)
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
CODING_TOOLS = [
|
|
44
|
+
"list_files", "read_file", "search_code", "edit_file", "create_file",
|
|
45
|
+
"run_command", "git_status", "git_log", "git_diff",
|
|
46
|
+
"run_test", "run_lint", "run_build",
|
|
47
|
+
"project_info", "detect_checks",
|
|
48
|
+
]
|
|
49
|
+
RESEARCH_TOOLS = ["web_search", "fetch_web_page"]
|
|
50
|
+
IMAGE_TOOLS = ["generate_image"]
|
|
51
|
+
VIDEO_TOOLS = ["generate_video"]
|
|
52
|
+
GENERAL_TOOLS = [
|
|
53
|
+
"list_files", "read_file", "search_code", "search_knowledge", "remember",
|
|
54
|
+
"project_info",
|
|
55
|
+
]
|
|
56
|
+
MAIN_TOOLS = sorted(set(
|
|
57
|
+
CODING_TOOLS + RESEARCH_TOOLS + IMAGE_TOOLS + VIDEO_TOOLS + GENERAL_TOOLS
|
|
58
|
+
))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
MAIN = AgentDefinition(
|
|
62
|
+
name="main",
|
|
63
|
+
purpose="Coordinator — routes requests to the right specialist agent.",
|
|
64
|
+
tools=["list_agents"],
|
|
65
|
+
system_additions=(
|
|
66
|
+
"You are ElJay AI, the main coordinator. You route user requests to "
|
|
67
|
+
"specialized sub-agents when beneficial, but you can also handle "
|
|
68
|
+
"general conversation directly."
|
|
69
|
+
),
|
|
70
|
+
priority=0,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
CODING = AgentDefinition(
|
|
74
|
+
name="coding",
|
|
75
|
+
purpose="Coding — inspect, read, modify, test, and verify project code.",
|
|
76
|
+
tools=CODING_TOOLS,
|
|
77
|
+
providers=["ollama"],
|
|
78
|
+
system_additions=(
|
|
79
|
+
"You are the Coding Agent. Follow this workflow:\n"
|
|
80
|
+
"1. Inspect the project structure\n"
|
|
81
|
+
"2. Understand the codebase\n"
|
|
82
|
+
"3. Explain the problem\n"
|
|
83
|
+
"4. Propose a fix\n"
|
|
84
|
+
"5. Ask for permission before risky changes\n"
|
|
85
|
+
"6. Modify files\n"
|
|
86
|
+
"7. Verify the actual tool result\n"
|
|
87
|
+
"Never claim a change worked without verifying."
|
|
88
|
+
),
|
|
89
|
+
keywords=["fix", "bug", "error", "code", "function", "method", "refactor",
|
|
90
|
+
"file", "test", "build", "lint", "compile", "php", "laravel",
|
|
91
|
+
"react", "vue", "python", "api", "endpoint"],
|
|
92
|
+
priority=3,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
RESEARCH = AgentDefinition(
|
|
96
|
+
name="research",
|
|
97
|
+
purpose="Web research — search, collect, compare, and summarize sources.",
|
|
98
|
+
tools=RESEARCH_TOOLS,
|
|
99
|
+
providers=["duckduckgo"],
|
|
100
|
+
system_additions=(
|
|
101
|
+
"You are the Research Agent. For each question:\n"
|
|
102
|
+
"1. Search for relevant sources\n"
|
|
103
|
+
"2. Fetch and extract key information\n"
|
|
104
|
+
"3. Compare sources\n"
|
|
105
|
+
"4. Summarize with clear source attribution\n"
|
|
106
|
+
"Distinguish: direct quotes, model reasoning, and uncertainty.\n"
|
|
107
|
+
"Never fabricate citations or pretend a source was consulted."
|
|
108
|
+
),
|
|
109
|
+
keywords=["research", "latest", "current", "upcoming", "recent", "update",
|
|
110
|
+
"new in", "documentation", "docs", "best practice", "comparison"],
|
|
111
|
+
priority=2,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
IMAGE = AgentDefinition(
|
|
115
|
+
name="image",
|
|
116
|
+
purpose="Image generation via local / free back-ends.",
|
|
117
|
+
tools=IMAGE_TOOLS,
|
|
118
|
+
providers=["image-local"],
|
|
119
|
+
system_additions=(
|
|
120
|
+
"You are the Image Agent. You generate images from text prompts using "
|
|
121
|
+
"local providers. Report which provider/model was used. If no provider "
|
|
122
|
+
"is available, give the user the exact setup steps."
|
|
123
|
+
),
|
|
124
|
+
keywords=["image", "picture", "photo", "graphic", "draw", "create an image",
|
|
125
|
+
"generate an image", "render", "illustration", "art"],
|
|
126
|
+
priority=2,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
VIDEO = AgentDefinition(
|
|
130
|
+
name="video",
|
|
131
|
+
purpose="Video generation — concept and render via local back-ends.",
|
|
132
|
+
tools=VIDEO_TOOLS,
|
|
133
|
+
providers=["video-local"],
|
|
134
|
+
system_additions=(
|
|
135
|
+
"You are the Video Agent. You create video concepts and, where a local "
|
|
136
|
+
"GPU back-end exists, generate video. Report hardware requirements "
|
|
137
|
+
"honestly. If generation is unavailable, describe the concept and the "
|
|
138
|
+
"setup needed."
|
|
139
|
+
),
|
|
140
|
+
keywords=["video", "clip", "animation", "motion", "cinematic", "music video",
|
|
141
|
+
"animate"],
|
|
142
|
+
priority=2,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
KNOWLEDGE = AgentDefinition(
|
|
146
|
+
name="knowledge",
|
|
147
|
+
purpose="General knowledge — answers, explanations, creative writing.",
|
|
148
|
+
tools=GENERAL_TOOLS,
|
|
149
|
+
providers=["ollama"],
|
|
150
|
+
system_additions=(
|
|
151
|
+
"You are the General Knowledge Agent. Answer questions, explain "
|
|
152
|
+
"concepts, help with writing, and provide ideas. Use web research "
|
|
153
|
+
"only when the user asks for current information."
|
|
154
|
+
),
|
|
155
|
+
keywords=["what is", "explain", "how to", "why", "describe", "what are",
|
|
156
|
+
"definition", "meaning", "help me write", "email", "birthday"],
|
|
157
|
+
priority=1,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
AGENTS = [MAIN, CODING, RESEARCH, IMAGE, VIDEO, KNOWLEDGE]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_agent(name: str) -> Optional[AgentDefinition]:
|
|
165
|
+
"""Look up an agent by name."""
|
|
166
|
+
for agent in AGENTS:
|
|
167
|
+
if agent.name == name:
|
|
168
|
+
return agent
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def list_agents() -> list[AgentDefinition]:
|
|
173
|
+
"""Return all agents sorted by priority (most specific first)."""
|
|
174
|
+
return sorted(AGENTS, key=lambda a: -a.priority)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _keyword_match(keyword: str, query: str) -> bool:
|
|
178
|
+
"""Return True if *keyword* appears as a whole-word phrase in *query*.
|
|
179
|
+
|
|
180
|
+
Uses regex word boundaries so 'test' does not match inside 'latest'.
|
|
181
|
+
"""
|
|
182
|
+
pattern = r"\b" + re.escape(keyword) + r"\b"
|
|
183
|
+
return re.search(pattern, query) is not None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def route_request(query: str, providers_available: dict[str, bool] | None = None) -> AgentDefinition:
|
|
187
|
+
"""Route *query* to the best agent via keyword matching.
|
|
188
|
+
|
|
189
|
+
Falls back to the Main coordinator when no specific agent matches, or
|
|
190
|
+
when a required provider is unavailable (the agent would just error).
|
|
191
|
+
"""
|
|
192
|
+
query_lower = query.lower()
|
|
193
|
+
|
|
194
|
+
# Collect candidate agents that have a keyword present in the query.
|
|
195
|
+
candidates = []
|
|
196
|
+
for agent in AGENTS:
|
|
197
|
+
if agent.name == "main":
|
|
198
|
+
continue
|
|
199
|
+
score = sum(1 for kw in agent.keywords if _keyword_match(kw, query_lower))
|
|
200
|
+
if score > 0:
|
|
201
|
+
# Note: we do NOT skip agents when their provider is unavailable.
|
|
202
|
+
# The agent itself reports unavailability and gives setup steps.
|
|
203
|
+
# This lets the user ask about image/video generation even when
|
|
204
|
+
# no local provider is configured.
|
|
205
|
+
candidates.append((agent, score))
|
|
206
|
+
|
|
207
|
+
if candidates:
|
|
208
|
+
# Highest score wins; highest priority breaks ties.
|
|
209
|
+
candidates.sort(key=lambda c: (c[1], c[0].priority), reverse=True)
|
|
210
|
+
return candidates[0][0]
|
|
211
|
+
|
|
212
|
+
return MAIN
|