eigencore 0.4.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.
- eigencore/__init__.py +82 -0
- eigencore/agent/__init__.py +45 -0
- eigencore/agent/executor.py +297 -0
- eigencore/agent/grammar.py +121 -0
- eigencore/agent/policy.py +262 -0
- eigencore/agent/react.py +261 -0
- eigencore/agent/tool.py +149 -0
- eigencore/analysis/__init__.py +3 -0
- eigencore/analysis/sparsity.py +184 -0
- eigencore/benchmark/__init__.py +33 -0
- eigencore/benchmark/harness.py +342 -0
- eigencore/cli/__init__.py +0 -0
- eigencore/cli/main.py +856 -0
- eigencore/context/__init__.py +3 -0
- eigencore/context/manager.py +318 -0
- eigencore/engine/__init__.py +3 -0
- eigencore/engine/inference.py +378 -0
- eigencore/engine/layer_skip.py +268 -0
- eigencore/engine/prompt_lookup.py +39 -0
- eigencore/engine/pruning.py +116 -0
- eigencore/engine/sparse_inference.py +218 -0
- eigencore/engine/sparsity_experiment.py +109 -0
- eigencore/engine/speculative.py +238 -0
- eigencore/fit/__init__.py +34 -0
- eigencore/fit/estimator.py +310 -0
- eigencore/fit/gguf_meta.py +130 -0
- eigencore/forge.py +216 -0
- eigencore/hal/__init__.py +3 -0
- eigencore/hal/_cpuid_win.py +119 -0
- eigencore/hal/profiler.py +349 -0
- eigencore/kernels/__init__.py +28 -0
- eigencore/kernels/q4k.py +76 -0
- eigencore/kernels/reference.py +168 -0
- eigencore/models/__init__.py +3 -0
- eigencore/models/registry.py +193 -0
- eigencore/router/__init__.py +3 -0
- eigencore/router/classifier.py +176 -0
- eigencore/training/__init__.py +3 -0
- eigencore/training/convergence.py +112 -0
- eigencore/training/trainer.py +401 -0
- eigencore/tui/__init__.py +5 -0
- eigencore/tui/chat.py +265 -0
- eigencore/tui/onboarding.py +134 -0
- eigencore/tui/theme.py +61 -0
- eigencore-0.4.0.dist-info/METADATA +144 -0
- eigencore-0.4.0.dist-info/RECORD +49 -0
- eigencore-0.4.0.dist-info/WHEEL +4 -0
- eigencore-0.4.0.dist-info/entry_points.txt +2 -0
- eigencore-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool policy engine — Phase 4 (guardrails).
|
|
3
|
+
|
|
4
|
+
Enforcement lives here, in code, below any UI: a tool call is denied unless
|
|
5
|
+
its capability is explicitly allow-listed, and path arguments are confined to
|
|
6
|
+
an authorized workspace root (symlinks resolved, traversal rejected). Every
|
|
7
|
+
decision is written to an append-only audit log with sensitive values
|
|
8
|
+
redacted.
|
|
9
|
+
|
|
10
|
+
This is a policy layer, NOT a sandbox: handlers still run in-process. OS-level
|
|
11
|
+
process isolation is a separate, later hardening step.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from enum import Enum, auto
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from eigencore.agent.tool import Tool, ToolCapability
|
|
25
|
+
|
|
26
|
+
# Parameter names that look like paths and must stay inside the workspace.
|
|
27
|
+
_PATH_PARAM_NAMES = {
|
|
28
|
+
"path",
|
|
29
|
+
"file",
|
|
30
|
+
"file_path",
|
|
31
|
+
"filename",
|
|
32
|
+
"target",
|
|
33
|
+
"dest",
|
|
34
|
+
"destination",
|
|
35
|
+
"dir",
|
|
36
|
+
"directory",
|
|
37
|
+
"folder",
|
|
38
|
+
"output_path",
|
|
39
|
+
"out",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Parameter names whose values must never appear in audit logs.
|
|
43
|
+
_SENSITIVE_PARAM_NAMES = {
|
|
44
|
+
"password",
|
|
45
|
+
"passwd",
|
|
46
|
+
"secret",
|
|
47
|
+
"token",
|
|
48
|
+
"api_key",
|
|
49
|
+
"apikey",
|
|
50
|
+
"access_key",
|
|
51
|
+
"private_key",
|
|
52
|
+
"auth",
|
|
53
|
+
"credential",
|
|
54
|
+
"credentials",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_SENSITIVE_RE = re.compile(
|
|
58
|
+
r"(?i)(password|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|authorization)\s*[:=]\s*\S+"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_path_param(name: str) -> bool:
|
|
63
|
+
return name.lower() in _PATH_PARAM_NAMES
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_sensitive_param(name: str) -> bool:
|
|
67
|
+
return name.lower() in _SENSITIVE_PARAM_NAMES
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def redact_text(text: str) -> str:
|
|
71
|
+
"""Scrub obvious credential assignments from free text (tool output, errors)."""
|
|
72
|
+
return _SENSITIVE_RE.sub(r"\1=***REDACTED***", text)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def redact_args(args: dict[str, Any]) -> dict[str, Any]:
|
|
76
|
+
return {k: ("***REDACTED***" if _is_sensitive_param(k) else v) for k, v in args.items()}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class PolicyDecision:
|
|
81
|
+
allowed: bool
|
|
82
|
+
reason: str
|
|
83
|
+
capability: ToolCapability | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class AuditEntry:
|
|
88
|
+
timestamp: str
|
|
89
|
+
tool_name: str
|
|
90
|
+
capability: str
|
|
91
|
+
args: dict[str, Any]
|
|
92
|
+
decision: str # "allowed" | "denied"
|
|
93
|
+
reason: str
|
|
94
|
+
status: str = ""
|
|
95
|
+
elapsed_seconds: float = 0.0
|
|
96
|
+
|
|
97
|
+
def to_dict(self) -> dict[str, Any]:
|
|
98
|
+
return {
|
|
99
|
+
"timestamp": self.timestamp,
|
|
100
|
+
"tool_name": self.tool_name,
|
|
101
|
+
"capability": self.capability,
|
|
102
|
+
"args": self.args,
|
|
103
|
+
"decision": self.decision,
|
|
104
|
+
"reason": self.reason,
|
|
105
|
+
"status": self.status,
|
|
106
|
+
"elapsed_seconds": self.elapsed_seconds,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class AuditLog:
|
|
111
|
+
"""Append-only JSONL audit trail for tool decisions and outcomes."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, path: Path | None = None):
|
|
114
|
+
self.path = Path(path) if path else None
|
|
115
|
+
self.entries: list[AuditEntry] = []
|
|
116
|
+
|
|
117
|
+
def record(self, entry: AuditEntry) -> None:
|
|
118
|
+
self.entries.append(entry)
|
|
119
|
+
if self.path is not None:
|
|
120
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
122
|
+
f.write(json.dumps(entry.to_dict(), default=str) + "\n")
|
|
123
|
+
|
|
124
|
+
def denied_count(self) -> int:
|
|
125
|
+
return sum(1 for e in self.entries if e.decision == "denied")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def resolve_in_workspace(path_value: str, workspace_root: Path) -> Path:
|
|
129
|
+
"""Resolve a path and require it to stay inside workspace_root.
|
|
130
|
+
|
|
131
|
+
Rejects absolute paths outside the root, `..` traversal, and symlinks
|
|
132
|
+
that resolve outside the root.
|
|
133
|
+
"""
|
|
134
|
+
root = workspace_root.resolve()
|
|
135
|
+
candidate = Path(path_value)
|
|
136
|
+
if not candidate.is_absolute():
|
|
137
|
+
candidate = root / candidate
|
|
138
|
+
resolved = candidate.resolve()
|
|
139
|
+
if resolved != root and root not in resolved.parents:
|
|
140
|
+
raise ValueError(f"Path {path_value!r} resolves to {resolved}, outside workspace {root}")
|
|
141
|
+
return resolved
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class PolicyEngine:
|
|
146
|
+
"""Decides whether a tool call may run.
|
|
147
|
+
|
|
148
|
+
- Deny by default: a capability must be present in `allowed_capabilities`.
|
|
149
|
+
- If `workspace_root` is set, path-like string args must resolve inside it.
|
|
150
|
+
- Every decision is recorded in `audit_log`.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
allowed_capabilities: set[ToolCapability] = field(default_factory=set)
|
|
154
|
+
workspace_root: Path | None = None
|
|
155
|
+
audit_log: AuditLog | None = None
|
|
156
|
+
|
|
157
|
+
def check(self, tool: Tool, args: dict[str, Any]) -> PolicyDecision:
|
|
158
|
+
cap = getattr(tool, "capability", ToolCapability.EXECUTE)
|
|
159
|
+
if cap not in self.allowed_capabilities:
|
|
160
|
+
decision = PolicyDecision(
|
|
161
|
+
allowed=False,
|
|
162
|
+
capability=cap,
|
|
163
|
+
reason=(
|
|
164
|
+
f"capability {cap.name} is not allow-listed "
|
|
165
|
+
f"(allowed: {sorted(c.name for c in self.allowed_capabilities) or 'none'})"
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
decision = PolicyDecision(
|
|
170
|
+
allowed=True, capability=cap, reason="capability allow-listed"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if decision.allowed and self.workspace_root is not None:
|
|
174
|
+
for param in tool.parameters:
|
|
175
|
+
if param.name in args and param.is_path and isinstance(args[param.name], str):
|
|
176
|
+
try:
|
|
177
|
+
resolve_in_workspace(args[param.name], self.workspace_root)
|
|
178
|
+
except ValueError as e:
|
|
179
|
+
decision = PolicyDecision(
|
|
180
|
+
allowed=False,
|
|
181
|
+
capability=cap,
|
|
182
|
+
reason=str(e),
|
|
183
|
+
)
|
|
184
|
+
break
|
|
185
|
+
|
|
186
|
+
if self.audit_log is not None:
|
|
187
|
+
self.audit_log.record(
|
|
188
|
+
AuditEntry(
|
|
189
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
190
|
+
tool_name=tool.name,
|
|
191
|
+
capability=cap.name,
|
|
192
|
+
args=redact_args(args),
|
|
193
|
+
decision="allowed" if decision.allowed else "denied",
|
|
194
|
+
reason=decision.reason,
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
return decision
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def default_policy(
|
|
201
|
+
workspace_root: Path | None = None, audit_log: AuditLog | None = None
|
|
202
|
+
) -> PolicyEngine:
|
|
203
|
+
"""Conservative default: read-only tools allowed, everything else denied."""
|
|
204
|
+
return PolicyEngine(
|
|
205
|
+
allowed_capabilities={ToolCapability.READ},
|
|
206
|
+
workspace_root=workspace_root,
|
|
207
|
+
audit_log=audit_log,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class ApprovalMode(Enum):
|
|
212
|
+
"""How a user approves a tool call. Enforcement stays in the policy engine;
|
|
213
|
+
this only records intent for the UI to ask."""
|
|
214
|
+
|
|
215
|
+
ASK = auto() # ask every time (default)
|
|
216
|
+
SESSION = auto() # approve once, allow same tool+capability for the session
|
|
217
|
+
DENY = auto() # deny this call
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass
|
|
221
|
+
class ApprovalRequest:
|
|
222
|
+
"""A pending tool call awaiting user approval."""
|
|
223
|
+
|
|
224
|
+
tool_name: str
|
|
225
|
+
capability: str
|
|
226
|
+
args: dict[str, Any] # already redacted for display
|
|
227
|
+
reason: str
|
|
228
|
+
|
|
229
|
+
def summary(self) -> str:
|
|
230
|
+
args = ", ".join(f"{k}={v!r}" for k, v in self.args.items())
|
|
231
|
+
return f"{self.tool_name}({args}) [{self.capability}] — {self.reason}"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class ApprovalManager:
|
|
235
|
+
"""Tracks approvals. The executor consults this AFTER policy passes but
|
|
236
|
+
BEFORE running; the UI renders the pending request and calls
|
|
237
|
+
`record()`. A session approval covers the same tool name only."""
|
|
238
|
+
|
|
239
|
+
def __init__(self, mode: ApprovalMode = ApprovalMode.ASK, audit_log: AuditLog | None = None):
|
|
240
|
+
self.mode = mode
|
|
241
|
+
self._session_approved: set[str] = set()
|
|
242
|
+
self.audit_log = audit_log
|
|
243
|
+
|
|
244
|
+
def needs_approval(self, tool_name: str) -> bool:
|
|
245
|
+
if self.mode is ApprovalMode.ASK:
|
|
246
|
+
return tool_name not in self._session_approved
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
def record(self, tool_name: str, approved: bool, session: bool = False) -> None:
|
|
250
|
+
if approved and session:
|
|
251
|
+
self._session_approved.add(tool_name)
|
|
252
|
+
if self.audit_log is not None:
|
|
253
|
+
self.audit_log.record(
|
|
254
|
+
AuditEntry(
|
|
255
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
256
|
+
tool_name=tool_name,
|
|
257
|
+
capability="approval",
|
|
258
|
+
args={"approved": approved, "session": session},
|
|
259
|
+
decision="approved" if approved else "denied",
|
|
260
|
+
reason="user approval",
|
|
261
|
+
)
|
|
262
|
+
)
|
eigencore/agent/react.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""ReAct agent — interleaved reasoning and action on CPU.
|
|
2
|
+
|
|
3
|
+
Implements the ReAct pattern (Yao et al., 2022): the model alternates
|
|
4
|
+
between Thought (reasoning about what to do), Action (calling a tool),
|
|
5
|
+
and Observation (reading the tool result). GBNF grammars force valid
|
|
6
|
+
JSON at each step so there are no parsing failures or retry loops.
|
|
7
|
+
|
|
8
|
+
The loop runs entirely on CPU using the existing InferenceEngine, with
|
|
9
|
+
Phase 2 optimizations (layer skipping, sparsity, speculative decoding)
|
|
10
|
+
active throughout.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import Enum, auto
|
|
19
|
+
from typing import Any, Optional
|
|
20
|
+
|
|
21
|
+
from eigencore.agent.executor import ToolExecutor, ToolResult
|
|
22
|
+
from eigencore.agent.tool import ToolRegistry
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StepKind(Enum):
|
|
26
|
+
THOUGHT = auto()
|
|
27
|
+
ACTION = auto()
|
|
28
|
+
OBSERVATION = auto()
|
|
29
|
+
FINAL = auto()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class AgentStep:
|
|
34
|
+
"""A single step in the ReAct loop."""
|
|
35
|
+
|
|
36
|
+
kind: StepKind
|
|
37
|
+
content: str
|
|
38
|
+
tool_name: Optional[str] = None
|
|
39
|
+
tool_args: Optional[dict[str, Any]] = None
|
|
40
|
+
tool_result: Optional[ToolResult] = None
|
|
41
|
+
elapsed_seconds: float = 0.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class AgentResult:
|
|
46
|
+
"""Complete result of an agent run."""
|
|
47
|
+
|
|
48
|
+
answer: str
|
|
49
|
+
steps: list[AgentStep] = field(default_factory=list)
|
|
50
|
+
total_seconds: float = 0.0
|
|
51
|
+
total_tokens: int = 0
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def num_thoughts(self) -> int:
|
|
55
|
+
return sum(1 for s in self.steps if s.kind == StepKind.THOUGHT)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def num_actions(self) -> int:
|
|
59
|
+
return sum(1 for s in self.steps if s.kind == StepKind.ACTION)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def tool_calls(self) -> list[AgentStep]:
|
|
63
|
+
return [s for s in self.steps if s.kind == StepKind.ACTION]
|
|
64
|
+
|
|
65
|
+
def format_trace(self) -> str:
|
|
66
|
+
lines: list[str] = []
|
|
67
|
+
for i, step in enumerate(self.steps, 1):
|
|
68
|
+
prefix = f"[{i}] {step.kind.name}"
|
|
69
|
+
if step.kind == StepKind.THOUGHT:
|
|
70
|
+
lines.append(f"{prefix}: {step.content}")
|
|
71
|
+
elif step.kind == StepKind.ACTION:
|
|
72
|
+
lines.append(f"{prefix}: {step.tool_name}({step.tool_args})")
|
|
73
|
+
elif step.kind == StepKind.OBSERVATION:
|
|
74
|
+
lines.append(f"{prefix}: {step.content[:200]}")
|
|
75
|
+
elif step.kind == StepKind.FINAL:
|
|
76
|
+
lines.append(f"{prefix}: {step.content}")
|
|
77
|
+
return "\n".join(lines)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_SYSTEM_PROMPT = """You are a helpful assistant with access to tools. To complete the user's task, reason step by step.
|
|
81
|
+
|
|
82
|
+
At each step, output ONE of these JSON formats:
|
|
83
|
+
|
|
84
|
+
To think about what to do:
|
|
85
|
+
{{"type": "thought", "content": "your reasoning here"}}
|
|
86
|
+
|
|
87
|
+
To call a tool:
|
|
88
|
+
{{"type": "action", "tool": "tool_name", "args": {{"param": "value"}}}}
|
|
89
|
+
|
|
90
|
+
To give your final answer (when you have enough information):
|
|
91
|
+
{{"type": "final", "content": "your answer here"}}
|
|
92
|
+
|
|
93
|
+
Available tools:
|
|
94
|
+
{tools}
|
|
95
|
+
|
|
96
|
+
Important:
|
|
97
|
+
- Think before acting. Plan your approach.
|
|
98
|
+
- After each tool result, decide if you need more information or can answer.
|
|
99
|
+
- Always give a final answer. Do not end with a thought or action."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ReActAgent:
|
|
103
|
+
"""ReAct agent that reasons and acts using CPU-local LLM inference.
|
|
104
|
+
|
|
105
|
+
Uses GBNF grammar-constrained decoding to guarantee valid JSON at
|
|
106
|
+
every step. The grammar mask is applied during sampling — no retries
|
|
107
|
+
or output validation needed.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
DEFAULT_MAX_STEPS = 10
|
|
111
|
+
DEFAULT_MAX_TOKENS_PER_STEP = 256
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
registry: ToolRegistry,
|
|
116
|
+
executor: Optional[ToolExecutor] = None,
|
|
117
|
+
max_steps: int = DEFAULT_MAX_STEPS,
|
|
118
|
+
max_tokens_per_step: int = DEFAULT_MAX_TOKENS_PER_STEP,
|
|
119
|
+
system_prompt: Optional[str] = None,
|
|
120
|
+
):
|
|
121
|
+
self.registry = registry
|
|
122
|
+
self.executor = executor or ToolExecutor(registry)
|
|
123
|
+
self.max_steps = max_steps
|
|
124
|
+
self.max_tokens_per_step = max_tokens_per_step
|
|
125
|
+
self._system_prompt = system_prompt
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def system_prompt(self) -> str:
|
|
129
|
+
base = self._system_prompt or _SYSTEM_PROMPT
|
|
130
|
+
return base.format(tools=self.registry.format_for_prompt())
|
|
131
|
+
|
|
132
|
+
def build_messages(self, task: str, history: list[AgentStep]) -> list[dict[str, str]]:
|
|
133
|
+
messages: list[dict[str, str]] = [
|
|
134
|
+
{"role": "system", "content": self.system_prompt},
|
|
135
|
+
{"role": "user", "content": task},
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
for step in history:
|
|
139
|
+
if step.kind == StepKind.THOUGHT:
|
|
140
|
+
msg = json.dumps({"type": "thought", "content": step.content})
|
|
141
|
+
messages.append({"role": "assistant", "content": msg})
|
|
142
|
+
elif step.kind == StepKind.ACTION:
|
|
143
|
+
msg = json.dumps(
|
|
144
|
+
{
|
|
145
|
+
"type": "action",
|
|
146
|
+
"tool": step.tool_name,
|
|
147
|
+
"args": step.tool_args,
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
messages.append({"role": "assistant", "content": msg})
|
|
151
|
+
elif step.kind == StepKind.OBSERVATION:
|
|
152
|
+
messages.append({"role": "user", "content": f"Observation: {step.content}"})
|
|
153
|
+
|
|
154
|
+
return messages
|
|
155
|
+
|
|
156
|
+
def parse_step(self, raw: str) -> AgentStep:
|
|
157
|
+
"""Parse a model output into an AgentStep.
|
|
158
|
+
|
|
159
|
+
Expects JSON constrained by GBNF grammar, but handles
|
|
160
|
+
malformed output gracefully.
|
|
161
|
+
"""
|
|
162
|
+
raw = raw.strip()
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
parsed = json.loads(raw)
|
|
166
|
+
except json.JSONDecodeError:
|
|
167
|
+
return AgentStep(
|
|
168
|
+
kind=StepKind.THOUGHT,
|
|
169
|
+
content=raw,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
step_type = parsed.get("type", "thought")
|
|
173
|
+
|
|
174
|
+
if step_type == "thought":
|
|
175
|
+
return AgentStep(
|
|
176
|
+
kind=StepKind.THOUGHT,
|
|
177
|
+
content=parsed.get("content", ""),
|
|
178
|
+
)
|
|
179
|
+
elif step_type == "action":
|
|
180
|
+
return AgentStep(
|
|
181
|
+
kind=StepKind.ACTION,
|
|
182
|
+
content="",
|
|
183
|
+
tool_name=parsed.get("tool"),
|
|
184
|
+
tool_args=parsed.get("args", {}),
|
|
185
|
+
)
|
|
186
|
+
elif step_type == "final":
|
|
187
|
+
return AgentStep(
|
|
188
|
+
kind=StepKind.FINAL,
|
|
189
|
+
content=parsed.get("content", ""),
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
return AgentStep(kind=StepKind.THOUGHT, content=raw)
|
|
193
|
+
|
|
194
|
+
def execute_step(self, step: AgentStep) -> Optional[AgentStep]:
|
|
195
|
+
if step.kind != StepKind.ACTION or step.tool_name is None:
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
result = self.executor.execute(step.tool_name, step.tool_args or {})
|
|
199
|
+
step.tool_result = result
|
|
200
|
+
|
|
201
|
+
return AgentStep(
|
|
202
|
+
kind=StepKind.OBSERVATION,
|
|
203
|
+
content=result.format_for_prompt(),
|
|
204
|
+
tool_result=result,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def run(
|
|
208
|
+
self,
|
|
209
|
+
task: str,
|
|
210
|
+
generate_fn=None,
|
|
211
|
+
) -> AgentResult:
|
|
212
|
+
"""Run the ReAct loop on a task.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
task: The user's task/question.
|
|
216
|
+
generate_fn: A callable(messages, max_tokens) -> str that runs
|
|
217
|
+
inference. When None, the agent builds the step history
|
|
218
|
+
but requires an external caller to drive generation.
|
|
219
|
+
"""
|
|
220
|
+
start = time.perf_counter()
|
|
221
|
+
steps: list[AgentStep] = []
|
|
222
|
+
total_tokens = 0
|
|
223
|
+
|
|
224
|
+
for _ in range(self.max_steps):
|
|
225
|
+
if generate_fn is None:
|
|
226
|
+
break
|
|
227
|
+
|
|
228
|
+
messages = self.build_messages(task, steps)
|
|
229
|
+
step_start = time.perf_counter()
|
|
230
|
+
raw_output = generate_fn(messages, self.max_tokens_per_step)
|
|
231
|
+
step_elapsed = time.perf_counter() - step_start
|
|
232
|
+
|
|
233
|
+
step = self.parse_step(raw_output)
|
|
234
|
+
step.elapsed_seconds = step_elapsed
|
|
235
|
+
steps.append(step)
|
|
236
|
+
total_tokens += len(raw_output) // 4
|
|
237
|
+
|
|
238
|
+
if step.kind == StepKind.FINAL:
|
|
239
|
+
break
|
|
240
|
+
|
|
241
|
+
if step.kind == StepKind.ACTION:
|
|
242
|
+
observation = self.execute_step(step)
|
|
243
|
+
if observation:
|
|
244
|
+
steps.append(observation)
|
|
245
|
+
|
|
246
|
+
answer = ""
|
|
247
|
+
for step in reversed(steps):
|
|
248
|
+
if step.kind == StepKind.FINAL:
|
|
249
|
+
answer = step.content
|
|
250
|
+
break
|
|
251
|
+
if not answer and steps:
|
|
252
|
+
last_content = [s for s in steps if s.content]
|
|
253
|
+
if last_content:
|
|
254
|
+
answer = last_content[-1].content
|
|
255
|
+
|
|
256
|
+
return AgentResult(
|
|
257
|
+
answer=answer,
|
|
258
|
+
steps=steps,
|
|
259
|
+
total_seconds=time.perf_counter() - start,
|
|
260
|
+
total_tokens=total_tokens,
|
|
261
|
+
)
|
eigencore/agent/tool.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Tool definitions and registry for agent function calling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum, auto
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ToolCapability(Enum):
|
|
12
|
+
"""What a tool is allowed to do. Deny-by-default: nothing runs unless allowed."""
|
|
13
|
+
|
|
14
|
+
READ = auto() # read files / pure computation
|
|
15
|
+
WRITE = auto() # modify files
|
|
16
|
+
EXECUTE = auto() # run processes / shell
|
|
17
|
+
NETWORK = auto() # network access
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ToolParam:
|
|
22
|
+
"""A single parameter in a tool's signature."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
type: str # "string", "integer", "number", "boolean"
|
|
26
|
+
description: str
|
|
27
|
+
required: bool = True
|
|
28
|
+
enum: tuple[str, ...] | None = None
|
|
29
|
+
default: Any = None
|
|
30
|
+
is_path: bool = False # declare explicitly; policy confines these to the workspace
|
|
31
|
+
|
|
32
|
+
_TYPE_MAP = {
|
|
33
|
+
"string": str,
|
|
34
|
+
"integer": int,
|
|
35
|
+
"number": (int, float),
|
|
36
|
+
"boolean": bool,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def validate(self, value: Any) -> Any:
|
|
40
|
+
expected = self._TYPE_MAP.get(self.type)
|
|
41
|
+
if expected is None:
|
|
42
|
+
raise ValueError(f"Unknown param type: {self.type}")
|
|
43
|
+
|
|
44
|
+
if not isinstance(value, expected):
|
|
45
|
+
if self.type == "number" and isinstance(value, int):
|
|
46
|
+
value = float(value)
|
|
47
|
+
elif self.type == "integer" and isinstance(value, float) and value == int(value):
|
|
48
|
+
value = int(value)
|
|
49
|
+
else:
|
|
50
|
+
raise TypeError(
|
|
51
|
+
f"Parameter '{self.name}' expects {self.type}, got {type(value).__name__}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if self.enum and value not in self.enum:
|
|
55
|
+
raise ValueError(f"Parameter '{self.name}' must be one of {self.enum}, got {value!r}")
|
|
56
|
+
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Tool:
|
|
62
|
+
"""A callable tool that an agent can invoke."""
|
|
63
|
+
|
|
64
|
+
name: str
|
|
65
|
+
description: str
|
|
66
|
+
handler: Callable[..., Any]
|
|
67
|
+
parameters: list[ToolParam] = field(default_factory=list)
|
|
68
|
+
# What this tool does; the PolicyEngine denies anything not allow-listed.
|
|
69
|
+
capability: ToolCapability = ToolCapability.EXECUTE
|
|
70
|
+
|
|
71
|
+
def __post_init__(self):
|
|
72
|
+
if not self.name.isidentifier():
|
|
73
|
+
raise ValueError(f"Tool name must be a valid identifier, got {self.name!r}")
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def required_params(self) -> list[ToolParam]:
|
|
77
|
+
return [p for p in self.parameters if p.required]
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def optional_params(self) -> list[ToolParam]:
|
|
81
|
+
return [p for p in self.parameters if not p.required]
|
|
82
|
+
|
|
83
|
+
def param_names(self) -> set[str]:
|
|
84
|
+
return {p.name for p in self.parameters}
|
|
85
|
+
|
|
86
|
+
def validate_args(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
87
|
+
validated: dict[str, Any] = {}
|
|
88
|
+
|
|
89
|
+
for param in self.required_params:
|
|
90
|
+
if param.name not in args:
|
|
91
|
+
raise ValueError(f"Missing required parameter: '{param.name}'")
|
|
92
|
+
|
|
93
|
+
for param in self.parameters:
|
|
94
|
+
if param.name in args:
|
|
95
|
+
validated[param.name] = param.validate(args[param.name])
|
|
96
|
+
elif not param.required and param.default is not None:
|
|
97
|
+
validated[param.name] = param.default
|
|
98
|
+
|
|
99
|
+
unknown = set(args.keys()) - self.param_names()
|
|
100
|
+
if unknown:
|
|
101
|
+
raise ValueError(f"Unknown parameters: {unknown}")
|
|
102
|
+
|
|
103
|
+
return validated
|
|
104
|
+
|
|
105
|
+
def format_for_prompt(self) -> str:
|
|
106
|
+
lines = [f"### {self.name}", f"{self.description}", "Parameters:"]
|
|
107
|
+
for p in self.parameters:
|
|
108
|
+
req = " (required)" if p.required else f" (optional, default={p.default!r})"
|
|
109
|
+
enum_hint = f", one of: {list(p.enum)}" if p.enum else ""
|
|
110
|
+
lines.append(f" - {p.name} ({p.type}{enum_hint}): {p.description}{req}")
|
|
111
|
+
return "\n".join(lines)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ToolRegistry:
|
|
115
|
+
"""Registry of tools available to an agent."""
|
|
116
|
+
|
|
117
|
+
def __init__(self):
|
|
118
|
+
self._tools: dict[str, Tool] = {}
|
|
119
|
+
|
|
120
|
+
def register(self, tool: Tool) -> None:
|
|
121
|
+
if tool.name in self._tools:
|
|
122
|
+
raise ValueError(f"Tool '{tool.name}' already registered")
|
|
123
|
+
self._tools[tool.name] = tool
|
|
124
|
+
|
|
125
|
+
def get(self, name: str) -> Tool | None:
|
|
126
|
+
return self._tools.get(name)
|
|
127
|
+
|
|
128
|
+
def remove(self, name: str) -> bool:
|
|
129
|
+
return self._tools.pop(name, None) is not None
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def tools(self) -> list[Tool]:
|
|
133
|
+
return list(self._tools.values())
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def tool_names(self) -> list[str]:
|
|
137
|
+
return list(self._tools.keys())
|
|
138
|
+
|
|
139
|
+
def __len__(self) -> int:
|
|
140
|
+
return len(self._tools)
|
|
141
|
+
|
|
142
|
+
def __contains__(self, name: str) -> bool:
|
|
143
|
+
return name in self._tools
|
|
144
|
+
|
|
145
|
+
def format_for_prompt(self) -> str:
|
|
146
|
+
if not self._tools:
|
|
147
|
+
return "No tools available."
|
|
148
|
+
sections = [tool.format_for_prompt() for tool in self._tools.values()]
|
|
149
|
+
return "\n\n".join(sections)
|