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.
Files changed (64) hide show
  1. package/AGENTS.md +57 -0
  2. package/README.md +479 -0
  3. package/agent/__init__.py +10 -0
  4. package/agent/agent.py +269 -0
  5. package/agent/agents/__init__.py +34 -0
  6. package/agent/agents/registry.py +212 -0
  7. package/agent/builtin_tools.py +591 -0
  8. package/agent/chat.py +354 -0
  9. package/agent/config.py +67 -0
  10. package/agent/context.py +63 -0
  11. package/agent/edits.py +167 -0
  12. package/agent/gitaware.py +126 -0
  13. package/agent/hardware.py +331 -0
  14. package/agent/knowledge.py +133 -0
  15. package/agent/memory.py +98 -0
  16. package/agent/ollama_client.py +133 -0
  17. package/agent/permissions.py +93 -0
  18. package/agent/providers/__init__.py +51 -0
  19. package/agent/providers/base.py +78 -0
  20. package/agent/providers/image.py +110 -0
  21. package/agent/providers/ollama.py +111 -0
  22. package/agent/providers/video.py +96 -0
  23. package/agent/providers/web.py +155 -0
  24. package/agent/router.py +200 -0
  25. package/agent/rules.py +52 -0
  26. package/agent/runner.py +168 -0
  27. package/agent/skills.py +161 -0
  28. package/agent/tools.py +102 -0
  29. package/agent/verification.py +80 -0
  30. package/agent/workspace.py +487 -0
  31. package/bin/eljay +5 -0
  32. package/bin/eljay.cmd +4 -0
  33. package/bin/myagent +4 -0
  34. package/bin/myagent.cmd +4 -0
  35. package/eljay-ai-1.1.0.tgz +0 -0
  36. package/eljay.js +56 -0
  37. package/eljay.py +150 -0
  38. package/install.ps1 +28 -0
  39. package/knowledge/reference/diffusers.md +32 -0
  40. package/knowledge/reference/video-providers.md +21 -0
  41. package/knowledge/setup/comfyui.md +35 -0
  42. package/knowledge/setup/image-providers.md +16 -0
  43. package/knowledge/test-category/test-entry.md +5 -0
  44. package/myagent.py +30 -0
  45. package/package.json +40 -0
  46. package/skills/coding/code-review.md +3 -0
  47. package/skills/coding/fix-attempt-protocol.md +9 -0
  48. package/skills/debugging/root-cause-analysis.md +9 -0
  49. package/skills/general/communication.md +7 -0
  50. package/skills/laravel/authentication.md +15 -0
  51. package/skills/mysql/performance.md +9 -0
  52. package/skills/php/standards.md +8 -0
  53. package/skills/react/component-best-practices.md +8 -0
  54. package/skills/research/source-tracking.md +7 -0
  55. package/skills/security/input-validation.md +9 -0
  56. package/skills/testing/pytest-best-practices.md +7 -0
  57. package/tests/run_all.py +34 -0
  58. package/tests/test_agent_core.py +385 -0
  59. package/tests/test_capabilities.py +169 -0
  60. package/tests/test_edits.py +117 -0
  61. package/tests/test_eljay.py +209 -0
  62. package/tests/test_runner.py +114 -0
  63. package/tests/test_universal.py +458 -0
  64. 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