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.
Files changed (49) hide show
  1. eigencore/__init__.py +82 -0
  2. eigencore/agent/__init__.py +45 -0
  3. eigencore/agent/executor.py +297 -0
  4. eigencore/agent/grammar.py +121 -0
  5. eigencore/agent/policy.py +262 -0
  6. eigencore/agent/react.py +261 -0
  7. eigencore/agent/tool.py +149 -0
  8. eigencore/analysis/__init__.py +3 -0
  9. eigencore/analysis/sparsity.py +184 -0
  10. eigencore/benchmark/__init__.py +33 -0
  11. eigencore/benchmark/harness.py +342 -0
  12. eigencore/cli/__init__.py +0 -0
  13. eigencore/cli/main.py +856 -0
  14. eigencore/context/__init__.py +3 -0
  15. eigencore/context/manager.py +318 -0
  16. eigencore/engine/__init__.py +3 -0
  17. eigencore/engine/inference.py +378 -0
  18. eigencore/engine/layer_skip.py +268 -0
  19. eigencore/engine/prompt_lookup.py +39 -0
  20. eigencore/engine/pruning.py +116 -0
  21. eigencore/engine/sparse_inference.py +218 -0
  22. eigencore/engine/sparsity_experiment.py +109 -0
  23. eigencore/engine/speculative.py +238 -0
  24. eigencore/fit/__init__.py +34 -0
  25. eigencore/fit/estimator.py +310 -0
  26. eigencore/fit/gguf_meta.py +130 -0
  27. eigencore/forge.py +216 -0
  28. eigencore/hal/__init__.py +3 -0
  29. eigencore/hal/_cpuid_win.py +119 -0
  30. eigencore/hal/profiler.py +349 -0
  31. eigencore/kernels/__init__.py +28 -0
  32. eigencore/kernels/q4k.py +76 -0
  33. eigencore/kernels/reference.py +168 -0
  34. eigencore/models/__init__.py +3 -0
  35. eigencore/models/registry.py +193 -0
  36. eigencore/router/__init__.py +3 -0
  37. eigencore/router/classifier.py +176 -0
  38. eigencore/training/__init__.py +3 -0
  39. eigencore/training/convergence.py +112 -0
  40. eigencore/training/trainer.py +401 -0
  41. eigencore/tui/__init__.py +5 -0
  42. eigencore/tui/chat.py +265 -0
  43. eigencore/tui/onboarding.py +134 -0
  44. eigencore/tui/theme.py +61 -0
  45. eigencore-0.4.0.dist-info/METADATA +144 -0
  46. eigencore-0.4.0.dist-info/RECORD +49 -0
  47. eigencore-0.4.0.dist-info/WHEEL +4 -0
  48. eigencore-0.4.0.dist-info/entry_points.txt +2 -0
  49. 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
+ )
@@ -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
+ )
@@ -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)
@@ -0,0 +1,3 @@
1
+ from eigencore.analysis.sparsity import SparsityAnalyzer
2
+
3
+ __all__ = ["SparsityAnalyzer"]