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
eigencore/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""EigenCore — CPU-first LLM intelligence runtime."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.4.0"
|
|
4
|
+
|
|
5
|
+
from eigencore.agent.executor import ToolExecutor, ToolResult
|
|
6
|
+
from eigencore.agent.grammar import GBNFBuilder
|
|
7
|
+
from eigencore.agent.policy import AuditLog, PolicyEngine, ToolCapability, default_policy
|
|
8
|
+
from eigencore.agent.react import AgentResult, AgentStep, ReActAgent, StepKind
|
|
9
|
+
from eigencore.agent.tool import Tool, ToolParam, ToolRegistry
|
|
10
|
+
from eigencore.benchmark import (
|
|
11
|
+
DEFAULT_CASES,
|
|
12
|
+
BenchmarkReport,
|
|
13
|
+
QualityCase,
|
|
14
|
+
build_report,
|
|
15
|
+
run_cases,
|
|
16
|
+
)
|
|
17
|
+
from eigencore.context.manager import ContextManager
|
|
18
|
+
from eigencore.engine.inference import GenerationConfig, GenerationResult, InferenceEngine
|
|
19
|
+
from eigencore.engine.layer_skip import LayerSkipScheduler, SkipPlan, SkipStrategy
|
|
20
|
+
from eigencore.engine.sparse_inference import SparseExecutionPlan, SparsityCache, SparsityPredictor
|
|
21
|
+
from eigencore.engine.speculative import SpeculativeDecoder, SpeculativeResult
|
|
22
|
+
from eigencore.fit import (
|
|
23
|
+
FitEstimate,
|
|
24
|
+
FitVerdict,
|
|
25
|
+
ModelGeometry,
|
|
26
|
+
estimate_fit,
|
|
27
|
+
max_context_for_budget,
|
|
28
|
+
)
|
|
29
|
+
from eigencore.forge import Forge
|
|
30
|
+
from eigencore.hal.profiler import HardwareProfile, profile_hardware
|
|
31
|
+
from eigencore.kernels import ScalarBackend, rms_norm
|
|
32
|
+
from eigencore.models.registry import ModelRegistry, ModelSpec
|
|
33
|
+
from eigencore.router.classifier import RoutingDecision, TaskDomain, TaskRouter
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"DEFAULT_CASES",
|
|
37
|
+
"AgentResult",
|
|
38
|
+
"AgentStep",
|
|
39
|
+
"AuditLog",
|
|
40
|
+
"BenchmarkReport",
|
|
41
|
+
"ContextManager",
|
|
42
|
+
"FitEstimate",
|
|
43
|
+
"FitVerdict",
|
|
44
|
+
"Forge",
|
|
45
|
+
"GBNFBuilder",
|
|
46
|
+
"GenerationConfig",
|
|
47
|
+
"GenerationResult",
|
|
48
|
+
"HardwareProfile",
|
|
49
|
+
"InferenceEngine",
|
|
50
|
+
"LayerSkipScheduler",
|
|
51
|
+
"ModelGeometry",
|
|
52
|
+
"ModelRegistry",
|
|
53
|
+
"ModelSpec",
|
|
54
|
+
"PolicyEngine",
|
|
55
|
+
"QualityCase",
|
|
56
|
+
"ReActAgent",
|
|
57
|
+
"RoutingDecision",
|
|
58
|
+
"ScalarBackend",
|
|
59
|
+
"SkipPlan",
|
|
60
|
+
"SkipStrategy",
|
|
61
|
+
"SparseExecutionPlan",
|
|
62
|
+
"SparsityCache",
|
|
63
|
+
"SparsityPredictor",
|
|
64
|
+
"SpeculativeDecoder",
|
|
65
|
+
"SpeculativeResult",
|
|
66
|
+
"StepKind",
|
|
67
|
+
"TaskDomain",
|
|
68
|
+
"TaskRouter",
|
|
69
|
+
"Tool",
|
|
70
|
+
"ToolCapability",
|
|
71
|
+
"ToolExecutor",
|
|
72
|
+
"ToolParam",
|
|
73
|
+
"ToolRegistry",
|
|
74
|
+
"ToolResult",
|
|
75
|
+
"build_report",
|
|
76
|
+
"default_policy",
|
|
77
|
+
"estimate_fit",
|
|
78
|
+
"max_context_for_budget",
|
|
79
|
+
"profile_hardware",
|
|
80
|
+
"rms_norm",
|
|
81
|
+
"run_cases",
|
|
82
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Agent framework — tool calling, GBNF grammars, and ReAct reasoning."""
|
|
2
|
+
|
|
3
|
+
from eigencore.agent.executor import ToolExecutor, ToolResult
|
|
4
|
+
from eigencore.agent.grammar import GBNFBuilder
|
|
5
|
+
from eigencore.agent.policy import (
|
|
6
|
+
ApprovalManager,
|
|
7
|
+
ApprovalMode,
|
|
8
|
+
ApprovalRequest,
|
|
9
|
+
AuditEntry,
|
|
10
|
+
AuditLog,
|
|
11
|
+
PolicyDecision,
|
|
12
|
+
PolicyEngine,
|
|
13
|
+
ToolCapability,
|
|
14
|
+
default_policy,
|
|
15
|
+
redact_args,
|
|
16
|
+
redact_text,
|
|
17
|
+
resolve_in_workspace,
|
|
18
|
+
)
|
|
19
|
+
from eigencore.agent.react import AgentResult, AgentStep, ReActAgent, StepKind
|
|
20
|
+
from eigencore.agent.tool import Tool, ToolParam, ToolRegistry
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ApprovalManager",
|
|
24
|
+
"ApprovalMode",
|
|
25
|
+
"ApprovalRequest",
|
|
26
|
+
"AgentResult",
|
|
27
|
+
"AgentStep",
|
|
28
|
+
"AuditEntry",
|
|
29
|
+
"AuditLog",
|
|
30
|
+
"GBNFBuilder",
|
|
31
|
+
"PolicyDecision",
|
|
32
|
+
"PolicyEngine",
|
|
33
|
+
"ReActAgent",
|
|
34
|
+
"StepKind",
|
|
35
|
+
"Tool",
|
|
36
|
+
"ToolCapability",
|
|
37
|
+
"ToolExecutor",
|
|
38
|
+
"ToolParam",
|
|
39
|
+
"ToolRegistry",
|
|
40
|
+
"ToolResult",
|
|
41
|
+
"default_policy",
|
|
42
|
+
"redact_args",
|
|
43
|
+
"redact_text",
|
|
44
|
+
"resolve_in_workspace",
|
|
45
|
+
]
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Safe tool execution with validation and timeouts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import pickle
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum, auto
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from eigencore.agent.policy import (
|
|
15
|
+
ApprovalManager,
|
|
16
|
+
PolicyEngine,
|
|
17
|
+
default_policy,
|
|
18
|
+
redact_text,
|
|
19
|
+
resolve_in_workspace,
|
|
20
|
+
)
|
|
21
|
+
from eigencore.agent.tool import Tool, ToolRegistry
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ToolStatus(Enum):
|
|
25
|
+
SUCCESS = auto()
|
|
26
|
+
ERROR = auto()
|
|
27
|
+
TIMEOUT = auto()
|
|
28
|
+
VALIDATION_ERROR = auto()
|
|
29
|
+
POLICY_DENIED = auto()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ToolResult:
|
|
34
|
+
"""Result of executing a tool."""
|
|
35
|
+
|
|
36
|
+
tool_name: str
|
|
37
|
+
status: ToolStatus
|
|
38
|
+
output: Any = None
|
|
39
|
+
error: str | None = None
|
|
40
|
+
elapsed_seconds: float = 0.0
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def ok(self) -> bool:
|
|
44
|
+
return self.status == ToolStatus.SUCCESS
|
|
45
|
+
|
|
46
|
+
def format_for_prompt(self) -> str:
|
|
47
|
+
if self.ok:
|
|
48
|
+
return f"[{self.tool_name}] Result: {self.output}"
|
|
49
|
+
return f"[{self.tool_name}] Error ({self.status.name}): {self.error}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ExecutionLog:
|
|
54
|
+
"""Tracks all tool executions in an agent run."""
|
|
55
|
+
|
|
56
|
+
entries: list[ToolResult] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
def add(self, result: ToolResult) -> None:
|
|
59
|
+
self.entries.append(result)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def total_calls(self) -> int:
|
|
63
|
+
return len(self.entries)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def success_count(self) -> int:
|
|
67
|
+
return sum(1 for e in self.entries if e.ok)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def error_count(self) -> int:
|
|
71
|
+
return sum(1 for e in self.entries if not e.ok)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def total_time(self) -> float:
|
|
75
|
+
return sum(e.elapsed_seconds for e in self.entries)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ToolExecutor:
|
|
79
|
+
"""Executes tools with policy enforcement, validation, killable timeouts, logging.
|
|
80
|
+
|
|
81
|
+
Deny-by-default: when no policy is passed, a `default_policy()` (READ-only,
|
|
82
|
+
workspace-confined) is used — an executor never runs a tool just because a
|
|
83
|
+
caller forgot to attach a policy.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
DEFAULT_TIMEOUT = 30.0
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
registry: ToolRegistry,
|
|
91
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
92
|
+
max_output_chars: int = 4096,
|
|
93
|
+
policy: PolicyEngine | None = None,
|
|
94
|
+
max_cpu_seconds: int = 60,
|
|
95
|
+
max_memory_mb: int = 4096,
|
|
96
|
+
max_file_size_mb: int = 64,
|
|
97
|
+
max_processes: int = 0, # 0 = do not set RLIMIT_NPROC (breaks library imports); >0 enables it
|
|
98
|
+
approver: ApprovalManager | None = None,
|
|
99
|
+
ask_fn=None, # callable(tool_name, args) -> bool; required when approver needs a human
|
|
100
|
+
):
|
|
101
|
+
self.registry = registry
|
|
102
|
+
self.timeout = timeout
|
|
103
|
+
self.max_output_chars = max_output_chars
|
|
104
|
+
self.policy = policy if policy is not None else default_policy()
|
|
105
|
+
# POSIX resource limits applied to the child process (best-effort;
|
|
106
|
+
# silently skipped on platforms without resource/preexec_fn).
|
|
107
|
+
self.max_cpu_seconds = max_cpu_seconds
|
|
108
|
+
self.max_memory_mb = max_memory_mb
|
|
109
|
+
self.max_file_size_mb = max_file_size_mb
|
|
110
|
+
self.max_processes = max_processes
|
|
111
|
+
self.approver = approver
|
|
112
|
+
self.ask_fn = ask_fn
|
|
113
|
+
self.log = ExecutionLog()
|
|
114
|
+
|
|
115
|
+
def execute(self, tool_name: str, args: dict[str, Any]) -> ToolResult:
|
|
116
|
+
tool = self.registry.get(tool_name)
|
|
117
|
+
if tool is None:
|
|
118
|
+
result = ToolResult(
|
|
119
|
+
tool_name=tool_name,
|
|
120
|
+
status=ToolStatus.ERROR,
|
|
121
|
+
error=f"Unknown tool: '{tool_name}'. Available: {self.registry.tool_names}",
|
|
122
|
+
)
|
|
123
|
+
self.log.add(result)
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
# Policy enforcement happens BEFORE validation/running, in code —
|
|
127
|
+
# never in prompt text and never in the UI layer.
|
|
128
|
+
decision = self.policy.check(tool, args)
|
|
129
|
+
if not decision.allowed:
|
|
130
|
+
result = ToolResult(
|
|
131
|
+
tool_name=tool_name,
|
|
132
|
+
status=ToolStatus.POLICY_DENIED,
|
|
133
|
+
error=f"Denied by policy: {decision.reason}",
|
|
134
|
+
)
|
|
135
|
+
self.log.add(result)
|
|
136
|
+
if self.policy.audit_log is not None:
|
|
137
|
+
entry = self.policy.audit_log.entries[-1]
|
|
138
|
+
entry.status = ToolStatus.POLICY_DENIED.name
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
# Approval gate: after policy, before validation/running. Enforcement
|
|
142
|
+
# is here in code; the UI only renders and collects the answer.
|
|
143
|
+
if self.approver is not None and self.approver.needs_approval(tool_name):
|
|
144
|
+
if self.ask_fn is None:
|
|
145
|
+
result = ToolResult(
|
|
146
|
+
tool_name=tool_name,
|
|
147
|
+
status=ToolStatus.POLICY_DENIED,
|
|
148
|
+
error="Denied: approval required but no approval callback is attached",
|
|
149
|
+
)
|
|
150
|
+
self.log.add(result)
|
|
151
|
+
return result
|
|
152
|
+
approved = bool(self.ask_fn(tool_name, args))
|
|
153
|
+
self.approver.record(tool_name, approved)
|
|
154
|
+
if not approved:
|
|
155
|
+
result = ToolResult(
|
|
156
|
+
tool_name=tool_name,
|
|
157
|
+
status=ToolStatus.POLICY_DENIED,
|
|
158
|
+
error="Denied by user approval",
|
|
159
|
+
)
|
|
160
|
+
self.log.add(result)
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
validated_args = tool.validate_args(args)
|
|
165
|
+
except (ValueError, TypeError) as e:
|
|
166
|
+
result = ToolResult(
|
|
167
|
+
tool_name=tool_name,
|
|
168
|
+
status=ToolStatus.VALIDATION_ERROR,
|
|
169
|
+
error=str(e),
|
|
170
|
+
)
|
|
171
|
+
self.log.add(result)
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
# Resolve declared path params to workspace-confined absolute paths
|
|
175
|
+
# BEFORE the handler sees them (no raw outside-workspace paths).
|
|
176
|
+
if self.policy.workspace_root is not None:
|
|
177
|
+
for param in tool.parameters:
|
|
178
|
+
if (
|
|
179
|
+
param.is_path
|
|
180
|
+
and param.name in validated_args
|
|
181
|
+
and isinstance(validated_args[param.name], str)
|
|
182
|
+
):
|
|
183
|
+
validated_args[param.name] = str(
|
|
184
|
+
resolve_in_workspace(validated_args[param.name], self.policy.workspace_root)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
result = self._run_with_timeout(tool, validated_args)
|
|
188
|
+
self.log.add(result)
|
|
189
|
+
if self.policy.audit_log is not None:
|
|
190
|
+
entry = self.policy.audit_log.entries[-1]
|
|
191
|
+
entry.status = result.status.name
|
|
192
|
+
entry.elapsed_seconds = result.elapsed_seconds
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
def _run_with_timeout(self, tool: Tool, args: dict[str, Any]) -> ToolResult:
|
|
196
|
+
"""Run the handler in a child `python` process; on timeout, kill it.
|
|
197
|
+
|
|
198
|
+
A thread-pool timeout cannot stop a running handler; a child process
|
|
199
|
+
can. Uses subprocess+pickle (not multiprocessing spawn) because spawn
|
|
200
|
+
is unreliable under pytest on Windows. Handlers must be picklable
|
|
201
|
+
(top-level functions) — lambdas/closures are rejected up front.
|
|
202
|
+
"""
|
|
203
|
+
start = time.perf_counter()
|
|
204
|
+
try:
|
|
205
|
+
payload = pickle.dumps((tool.handler, args))
|
|
206
|
+
except Exception as e:
|
|
207
|
+
return ToolResult(
|
|
208
|
+
tool_name=tool.name,
|
|
209
|
+
status=ToolStatus.ERROR,
|
|
210
|
+
error=f"handler/args not picklable for process isolation: {e}",
|
|
211
|
+
elapsed_seconds=time.perf_counter() - start,
|
|
212
|
+
)
|
|
213
|
+
proc = subprocess.Popen(
|
|
214
|
+
[sys.executable, "-c", _WORKER_CODE],
|
|
215
|
+
stdin=subprocess.PIPE,
|
|
216
|
+
stdout=subprocess.PIPE,
|
|
217
|
+
stderr=subprocess.DEVNULL,
|
|
218
|
+
preexec_fn=self._limits_fn() if os.name == "posix" else None,
|
|
219
|
+
)
|
|
220
|
+
try:
|
|
221
|
+
out, _ = proc.communicate(input=payload, timeout=self.timeout)
|
|
222
|
+
except subprocess.TimeoutExpired:
|
|
223
|
+
proc.kill()
|
|
224
|
+
proc.communicate()
|
|
225
|
+
return ToolResult(
|
|
226
|
+
tool_name=tool.name,
|
|
227
|
+
status=ToolStatus.TIMEOUT,
|
|
228
|
+
error=f"Tool execution timed out after {self.timeout}s (process killed)",
|
|
229
|
+
elapsed_seconds=self.timeout,
|
|
230
|
+
)
|
|
231
|
+
elapsed = time.perf_counter() - start
|
|
232
|
+
if proc.returncode != 0 or not out:
|
|
233
|
+
return ToolResult(
|
|
234
|
+
tool_name=tool.name,
|
|
235
|
+
status=ToolStatus.ERROR,
|
|
236
|
+
error=f"handler process exited with code {proc.returncode}",
|
|
237
|
+
elapsed_seconds=elapsed,
|
|
238
|
+
)
|
|
239
|
+
try:
|
|
240
|
+
kind, value = pickle.loads(out)
|
|
241
|
+
except Exception:
|
|
242
|
+
return ToolResult(
|
|
243
|
+
tool_name=tool.name,
|
|
244
|
+
status=ToolStatus.ERROR,
|
|
245
|
+
error="handler process returned an unreadable result",
|
|
246
|
+
elapsed_seconds=elapsed,
|
|
247
|
+
)
|
|
248
|
+
if kind == "ok":
|
|
249
|
+
output = value
|
|
250
|
+
if isinstance(value, str):
|
|
251
|
+
output = redact_text(value)
|
|
252
|
+
if len(output) > self.max_output_chars:
|
|
253
|
+
output = output[: self.max_output_chars] + "... [truncated]"
|
|
254
|
+
return ToolResult(
|
|
255
|
+
tool_name=tool.name,
|
|
256
|
+
status=ToolStatus.SUCCESS,
|
|
257
|
+
output=output,
|
|
258
|
+
elapsed_seconds=elapsed,
|
|
259
|
+
)
|
|
260
|
+
return ToolResult(
|
|
261
|
+
tool_name=tool.name,
|
|
262
|
+
status=ToolStatus.ERROR,
|
|
263
|
+
error=redact_text(value),
|
|
264
|
+
elapsed_seconds=elapsed,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def _limits_fn(self):
|
|
268
|
+
"""Build a preexec_fn applying rlimits to the child (POSIX only)."""
|
|
269
|
+
import resource
|
|
270
|
+
|
|
271
|
+
max_cpu = self.max_cpu_seconds
|
|
272
|
+
max_mem = self.max_memory_mb * 1024 * 1024
|
|
273
|
+
max_fsize = self.max_file_size_mb * 1024 * 1024
|
|
274
|
+
max_nproc = self.max_processes
|
|
275
|
+
|
|
276
|
+
def apply() -> None:
|
|
277
|
+
resource.setrlimit(resource.RLIMIT_CPU, (max_cpu, max_cpu + 5))
|
|
278
|
+
resource.setrlimit(resource.RLIMIT_AS, (max_mem, max_mem))
|
|
279
|
+
resource.setrlimit(resource.RLIMIT_FSIZE, (max_fsize, max_fsize))
|
|
280
|
+
if max_nproc > 0:
|
|
281
|
+
resource.setrlimit(resource.RLIMIT_NPROC, (max_nproc, max_nproc))
|
|
282
|
+
|
|
283
|
+
return apply
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
_WORKER_CODE = """
|
|
287
|
+
import pickle, sys, traceback
|
|
288
|
+
try:
|
|
289
|
+
handler, args = pickle.loads(sys.stdin.buffer.read())
|
|
290
|
+
try:
|
|
291
|
+
result = handler(**args)
|
|
292
|
+
sys.stdout.buffer.write(pickle.dumps(("ok", result)))
|
|
293
|
+
except Exception as e:
|
|
294
|
+
sys.stdout.buffer.write(pickle.dumps(("err", f"{type(e).__name__}: {e}\\n{traceback.format_exc()}")))
|
|
295
|
+
except Exception as e:
|
|
296
|
+
sys.stdout.buffer.write(pickle.dumps(("err", f"unpickle failed: {e}")))
|
|
297
|
+
"""
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""GBNF grammar builder for structured LLM output.
|
|
2
|
+
|
|
3
|
+
GBNF (GGML BNF) is llama.cpp's grammar format for constraining token
|
|
4
|
+
generation. On CPU this is nearly free — the grammar mask is applied
|
|
5
|
+
during sampling with no extra forward passes. This gives CPU inference
|
|
6
|
+
a structural advantage: guaranteed valid JSON tool calls without
|
|
7
|
+
retries or validation loops.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from eigencore.agent.tool import Tool, ToolRegistry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GBNFBuilder:
|
|
16
|
+
"""Builds GBNF grammars that constrain model output to valid structures."""
|
|
17
|
+
|
|
18
|
+
# reusable primitives
|
|
19
|
+
_WS = "ws ::= [ \\t\\n]*"
|
|
20
|
+
_STRING = r'string ::= "\"" ([^"\\] | "\\" .)* "\""'
|
|
21
|
+
_INTEGER = 'integer ::= "-"? [0-9]+'
|
|
22
|
+
_NUMBER = 'number ::= "-"? [0-9]+ ("." [0-9]+)?'
|
|
23
|
+
_BOOLEAN = 'boolean ::= "true" | "false"'
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def tool_call_grammar(cls, registry: ToolRegistry) -> str:
|
|
27
|
+
if not registry.tools:
|
|
28
|
+
raise ValueError("Cannot build grammar for empty tool registry")
|
|
29
|
+
|
|
30
|
+
rules: list[str] = []
|
|
31
|
+
tool_alternatives: list[str] = []
|
|
32
|
+
|
|
33
|
+
for tool in registry.tools:
|
|
34
|
+
rule_name = f"call_{tool.name}"
|
|
35
|
+
tool_alternatives.append(rule_name)
|
|
36
|
+
rules.append(cls._tool_rule(tool, rule_name))
|
|
37
|
+
|
|
38
|
+
root = 'root ::= ws "{" ws tool_call ws "}" ws'
|
|
39
|
+
tool_union = "tool_call ::= " + " | ".join(tool_alternatives)
|
|
40
|
+
|
|
41
|
+
parts = [
|
|
42
|
+
root,
|
|
43
|
+
tool_union,
|
|
44
|
+
*rules,
|
|
45
|
+
cls._WS,
|
|
46
|
+
cls._STRING,
|
|
47
|
+
cls._INTEGER,
|
|
48
|
+
cls._NUMBER,
|
|
49
|
+
cls._BOOLEAN,
|
|
50
|
+
]
|
|
51
|
+
return "\n".join(parts)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def json_object_grammar(cls, properties: dict[str, str]) -> str:
|
|
55
|
+
"""Build a grammar for a fixed JSON object shape.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
properties: mapping of key name → type ("string", "integer", "number", "boolean")
|
|
59
|
+
"""
|
|
60
|
+
if not properties:
|
|
61
|
+
return 'root ::= ws "{" ws "}" ws\n' + cls._WS
|
|
62
|
+
|
|
63
|
+
pairs: list[str] = []
|
|
64
|
+
for key, val_type in properties.items():
|
|
65
|
+
type_rule = cls._type_to_rule(val_type)
|
|
66
|
+
pairs.append(f'"\\"" "{key}" "\\"" ws ":" ws {type_rule}')
|
|
67
|
+
|
|
68
|
+
body = ' ws "," ws '.join(pairs)
|
|
69
|
+
root = f'root ::= ws "{{" ws {body} ws "}}" ws'
|
|
70
|
+
return "\n".join([root, cls._WS, cls._STRING, cls._INTEGER, cls._NUMBER, cls._BOOLEAN])
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def _tool_rule(cls, tool: Tool, rule_name: str) -> str:
|
|
74
|
+
name_literal = f'"\\"{tool.name}\\""'
|
|
75
|
+
tool_name_part = f'"\\"tool\\"" ws ":" ws {name_literal}'
|
|
76
|
+
|
|
77
|
+
if not tool.parameters:
|
|
78
|
+
args_part = '"\\"args\\"" ws ":" ws "{" ws "}"'
|
|
79
|
+
return f'{rule_name} ::= {tool_name_part} ws "," ws {args_part}'
|
|
80
|
+
|
|
81
|
+
param_pairs: list[str] = []
|
|
82
|
+
for param in tool.parameters:
|
|
83
|
+
type_rule = cls._type_to_rule(param.type)
|
|
84
|
+
if param.enum:
|
|
85
|
+
enum_alts = " | ".join(f'"\\"{v}\\""' for v in param.enum)
|
|
86
|
+
type_rule = f"({enum_alts})"
|
|
87
|
+
param_pair = f'"\\"{param.name}\\"" ws ":" ws {type_rule}'
|
|
88
|
+
param_pairs.append(param_pair)
|
|
89
|
+
|
|
90
|
+
args_body = ' ws "," ws '.join(param_pairs)
|
|
91
|
+
args_part = f'"\\"args\\"" ws ":" ws "{{" ws {args_body} ws "}}"'
|
|
92
|
+
return f'{rule_name} ::= {tool_name_part} ws "," ws {args_part}'
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _type_to_rule(type_name: str) -> str:
|
|
96
|
+
mapping = {
|
|
97
|
+
"string": "string",
|
|
98
|
+
"integer": "integer",
|
|
99
|
+
"number": "number",
|
|
100
|
+
"boolean": "boolean",
|
|
101
|
+
}
|
|
102
|
+
rule = mapping.get(type_name)
|
|
103
|
+
if rule is None:
|
|
104
|
+
raise ValueError(f"Unsupported type for GBNF: {type_name}")
|
|
105
|
+
return rule
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def thought_or_action_grammar(cls, registry: ToolRegistry) -> str:
|
|
109
|
+
"""Grammar for ReAct: model must output either a thought or a tool call."""
|
|
110
|
+
tool_grammar = cls.tool_call_grammar(registry)
|
|
111
|
+
|
|
112
|
+
thought_rule = 'thought ::= "\\"thought\\"" ws ":" ws string'
|
|
113
|
+
action_rule = 'action ::= "\\"action\\"" ws ":" ws "{" ws tool_call ws "}"'
|
|
114
|
+
|
|
115
|
+
new_root = 'root ::= ws "{" ws (thought_block | action_block) ws "}" ws'
|
|
116
|
+
thought_block = 'thought_block ::= "\\"type\\"" ws ":" ws "\\"thought\\"" ws "," ws thought'
|
|
117
|
+
action_block = 'action_block ::= "\\"type\\"" ws ":" ws "\\"action\\"" ws "," ws action'
|
|
118
|
+
|
|
119
|
+
remaining = "\n".join(tool_grammar.split("\n")[1:])
|
|
120
|
+
parts = [new_root, thought_block, action_block, thought_rule, action_rule, remaining]
|
|
121
|
+
return "\n".join(parts)
|