pal-code 0.1.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.
- pal/__init__.py +29 -0
- pal/__main__.py +3 -0
- pal/access_manager/__init__.py +16 -0
- pal/access_manager/components/__init__.py +14 -0
- pal/access_manager/components/access_control.py +41 -0
- pal/access_manager/components/base.py +39 -0
- pal/access_manager/components/intervention.py +31 -0
- pal/access_manager/components/privilege_registry.py +30 -0
- pal/access_manager/pipeline.py +68 -0
- pal/access_manager/utils/__init__.py +2 -0
- pal/agent_constraints/__init__.py +3 -0
- pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
- pal/agent_constraints/components/__init__.py +3 -0
- pal/agent_constraints/components/constraints.py +22 -0
- pal/agent_constraints/pipeline.py +22 -0
- pal/agent_constraints/utils/__init__.py +2 -0
- pal/agents/__init__.py +12 -0
- pal/agents/coding.py +14 -0
- pal/agents/docs.py +15 -0
- pal/chat/__init__.py +4 -0
- pal/chat/demo.py +231 -0
- pal/chat/providers.py +23 -0
- pal/chat/service.py +591 -0
- pal/chat/services/__init__.py +26 -0
- pal/chat/services/base.py +142 -0
- pal/chat/services/data.py +142 -0
- pal/chat/services/media.py +123 -0
- pal/chat/services/web.py +60 -0
- pal/chat/sessions.py +108 -0
- pal/chat/tools.py +446 -0
- pal/chat/uploads.py +81 -0
- pal/cli.py +502 -0
- pal/compression/__init__.py +12 -0
- pal/compression/components/__init__.py +64 -0
- pal/compression/components/adverbs.py +20 -0
- pal/compression/components/hedges.py +54 -0
- pal/compression/components/interjections.py +20 -0
- pal/compression/components/minifier.py +56 -0
- pal/compression/components/output_compressor.py +13 -0
- pal/compression/components/pleonasms.py +54 -0
- pal/compression/components/punctuations.py +36 -0
- pal/compression/components/stopwords.py +62 -0
- pal/compression/history.py +346 -0
- pal/compression/pipeline.py +58 -0
- pal/compression/utils/__init__.py +30 -0
- pal/compression/utils/negations.py +18 -0
- pal/compression/utils/nltk_setup.py +20 -0
- pal/compression/utils/pronouns.py +27 -0
- pal/compression/utils/proselint_setup.py +9 -0
- pal/compression/utils/spacy_setup.py +22 -0
- pal/config/default.yaml +163 -0
- pal/config.py +504 -0
- pal/context_manager/__init__.py +7 -0
- pal/context_manager/chat_context.py +117 -0
- pal/context_manager/components/__init__.py +5 -0
- pal/context_manager/components/assembler.py +75 -0
- pal/context_manager/components/context_cache.py +45 -0
- pal/context_manager/components/kv_bridge.py +16 -0
- pal/context_manager/components/pager.py +48 -0
- pal/context_manager/components/simple_context_manager.py +56 -0
- pal/context_manager/components/turns.py +88 -0
- pal/context_manager/pipeline.py +27 -0
- pal/context_manager/utils/__init__.py +18 -0
- pal/context_manager/utils/hashing.py +5 -0
- pal/context_manager/utils/relevance.py +59 -0
- pal/context_manager/utils/storage.py +37 -0
- pal/documents/__init__.py +4 -0
- pal/documents/components/__init__.py +0 -0
- pal/documents/components/chunker.py +79 -0
- pal/documents/components/index.py +155 -0
- pal/documents/utils/__init__.py +1 -0
- pal/errors.py +40 -0
- pal/input_converters/__init__.py +8 -0
- pal/input_converters/components/__init__.py +0 -0
- pal/input_converters/components/base.py +38 -0
- pal/input_converters/components/pandoc_converter.py +87 -0
- pal/input_converters/components/pdf_converter.py +46 -0
- pal/input_converters/components/pptx_converter.py +52 -0
- pal/input_converters/components/xlsx_converter.py +38 -0
- pal/input_converters/pipeline.py +72 -0
- pal/input_converters/utils/__init__.py +1 -0
- pal/input_converters/workspace.py +56 -0
- pal/input_savers/INPUT_RULES.md +2 -0
- pal/input_savers/__init__.py +5 -0
- pal/input_savers/components/__init__.py +0 -0
- pal/input_savers/components/base.py +40 -0
- pal/input_savers/components/methods.py +41 -0
- pal/input_savers/components/verifier.py +39 -0
- pal/input_savers/pipeline.py +154 -0
- pal/input_savers/utils/__init__.py +0 -0
- pal/input_savers/utils/guards.py +94 -0
- pal/knowledge_graph/__init__.py +10 -0
- pal/knowledge_graph/components/__init__.py +0 -0
- pal/knowledge_graph/components/base.py +50 -0
- pal/knowledge_graph/components/builder.py +54 -0
- pal/knowledge_graph/components/retriever.py +100 -0
- pal/knowledge_graph/components/workspace.py +116 -0
- pal/knowledge_graph/pipeline.py +215 -0
- pal/knowledge_graph/utils/__init__.py +1 -0
- pal/latency_tracker/__init__.py +3 -0
- pal/latency_tracker/pipeline.py +86 -0
- pal/llm_scheduler/__init__.py +11 -0
- pal/llm_scheduler/api.py +137 -0
- pal/llm_scheduler/code.py +28 -0
- pal/llm_scheduler/core.py +92 -0
- pal/llm_scheduler/local.py +215 -0
- pal/llm_scheduler/pipeline.py +42 -0
- pal/llm_scheduler/types.py +92 -0
- pal/llm_wrapper/__init__.py +34 -0
- pal/llm_wrapper/adapter.py +316 -0
- pal/llm_wrapper/components/__init__.py +23 -0
- pal/llm_wrapper/components/adapter_types.py +90 -0
- pal/llm_wrapper/components/base.py +195 -0
- pal/llm_wrapper/components/features.py +104 -0
- pal/llm_wrapper/components/litellm_wrapper.py +215 -0
- pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
- pal/llm_wrapper/modes.py +73 -0
- pal/llm_wrapper/pipeline.py +61 -0
- pal/llm_wrapper/utils/__init__.py +4 -0
- pal/llm_wrapper/utils/registry.py +49 -0
- pal/llm_wrapper/utils/token_counting.py +10 -0
- pal/local_llm/__init__.py +5 -0
- pal/local_llm/components/__init__.py +0 -0
- pal/local_llm/components/huggingface_local.py +217 -0
- pal/local_llm/components/local_llm.py +60 -0
- pal/logging_setup.py +18 -0
- pal/memory/__init__.py +7 -0
- pal/memory/components/__init__.py +6 -0
- pal/memory/components/lru_k.py +56 -0
- pal/memory/components/manager.py +98 -0
- pal/memory/components/note.py +54 -0
- pal/memory/components/storage_manager.py +60 -0
- pal/memory/pipeline.py +122 -0
- pal/memory/utils/__init__.py +3 -0
- pal/memory/utils/keyword_overlap.py +3 -0
- pal/memory_tracker/__init__.py +3 -0
- pal/memory_tracker/components/__init__.py +0 -0
- pal/memory_tracker/components/process.py +56 -0
- pal/memory_tracker/pipeline.py +44 -0
- pal/metrics/__init__.py +4 -0
- pal/metrics/components/__init__.py +4 -0
- pal/metrics/components/collector.py +140 -0
- pal/metrics/components/exporter.py +28 -0
- pal/metrics/pipeline.py +49 -0
- pal/metrics/utils/__init__.py +2 -0
- pal/notes_manager/__init__.py +5 -0
- pal/notes_manager/components/__init__.py +0 -0
- pal/notes_manager/components/privacy.py +14 -0
- pal/notes_manager/components/recall.py +31 -0
- pal/notes_manager/components/tidy.py +28 -0
- pal/notes_manager/pipeline.py +138 -0
- pal/orchestrator/__init__.py +26 -0
- pal/orchestrator/components/__init__.py +26 -0
- pal/orchestrator/components/agent.py +105 -0
- pal/orchestrator/components/cgroup_manager.py +209 -0
- pal/orchestrator/components/context_interrupt.py +220 -0
- pal/orchestrator/components/limits.py +9 -0
- pal/orchestrator/components/scheduler.py +69 -0
- pal/orchestrator/components/syscall_scheduler.py +200 -0
- pal/orchestrator/pipeline.py +228 -0
- pal/orchestrator/utils/__init__.py +4 -0
- pal/orchestrator/utils/device.py +164 -0
- pal/orchestrator/utils/protocol.py +32 -0
- pal/os_kernel/__init__.py +5 -0
- pal/os_kernel/filesystem.py +80 -0
- pal/os_kernel/network.py +146 -0
- pal/os_kernel/pipeline.py +175 -0
- pal/os_kernel/sysinfo.py +36 -0
- pal/paths.py +59 -0
- pal/pipeline.py +177 -0
- pal/py.typed +0 -0
- pal/redact.py +19 -0
- pal/sdk/__init__.py +33 -0
- pal/sdk/adapters.py +43 -0
- pal/sdk/agent.py +99 -0
- pal/sdk/components/__init__.py +14 -0
- pal/sdk/components/autogen_adapter.py +65 -0
- pal/sdk/components/autogen_client.py +98 -0
- pal/sdk/components/framework_adapter_base.py +69 -0
- pal/sdk/components/metagpt_adapter.py +46 -0
- pal/sdk/components/open_interpreter_adapter.py +47 -0
- pal/sdk/components/tool_catalog.py +44 -0
- pal/sdk/os_api.py +84 -0
- pal/sdk/patterns.py +129 -0
- pal/sdk/pipeline.py +240 -0
- pal/sdk/queries.py +68 -0
- pal/sdk/savers.py +88 -0
- pal/sdk/utils/__init__.py +2 -0
- pal/storage_manager/__init__.py +17 -0
- pal/storage_manager/components/__init__.py +19 -0
- pal/storage_manager/components/base.py +50 -0
- pal/storage_manager/components/local_file_backend.py +84 -0
- pal/storage_manager/components/storage_queue.py +91 -0
- pal/storage_manager/components/vector_store_backend.py +122 -0
- pal/storage_manager/pipeline.py +153 -0
- pal/storage_manager/utils/__init__.py +4 -0
- pal/storage_manager/utils/memory_adapter.py +60 -0
- pal/storage_manager/utils/sdk.py +93 -0
- pal/studio/__init__.py +0 -0
- pal/studio/builder.py +166 -0
- pal/studio/catalog.py +90 -0
- pal/studio/checks.py +129 -0
- pal/studio/clients.py +6 -0
- pal/studio/editor.py +137 -0
- pal/studio/files.py +6 -0
- pal/studio/models.py +6 -0
- pal/studio/plan.py +149 -0
- pal/studio/planner.py +205 -0
- pal/studio/policy.py +69 -0
- pal/studio/routes.py +254 -0
- pal/studio/runs.py +210 -0
- pal/task_router/__init__.py +11 -0
- pal/task_router/components/__init__.py +0 -0
- pal/task_router/components/answerer.py +37 -0
- pal/task_router/components/base.py +34 -0
- pal/task_router/components/classifier.py +40 -0
- pal/task_router/components/gates.py +23 -0
- pal/task_router/pipeline.py +61 -0
- pal/task_router/utils/__init__.py +1 -0
- pal/terminal/__init__.py +1 -0
- pal/terminal/backend.py +141 -0
- pal/terminal/bench.py +173 -0
- pal/terminal/cli.py +826 -0
- pal/terminal/clients.py +170 -0
- pal/terminal/commands.py +401 -0
- pal/terminal/decide.py +242 -0
- pal/terminal/files.py +55 -0
- pal/terminal/flow_cli.py +439 -0
- pal/terminal/hardware.py +199 -0
- pal/terminal/history.py +88 -0
- pal/terminal/keys.py +83 -0
- pal/terminal/local_models.py +261 -0
- pal/terminal/logo.py +117 -0
- pal/terminal/models.py +99 -0
- pal/terminal/policy.py +54 -0
- pal/terminal/project.py +413 -0
- pal/terminal/review.py +65 -0
- pal/terminal/security.py +239 -0
- pal/terminal/session.py +503 -0
- pal/terminal/setup.py +354 -0
- pal/terminal/syntax.py +98 -0
- pal/terminal/theme.py +37 -0
- pal/terminal/ui.py +647 -0
- pal/terminal/voice.py +137 -0
- pal/terminal/workflow.py +624 -0
- pal/token_tracker/__init__.py +3 -0
- pal/token_tracker/pipeline.py +54 -0
- pal/tokens.py +21 -0
- pal/tool_catalog.py +107 -0
- pal/tool_manager/__init__.py +17 -0
- pal/tool_manager/builtin.py +82 -0
- pal/tool_manager/components/__init__.py +19 -0
- pal/tool_manager/components/base.py +53 -0
- pal/tool_manager/components/conflict_resolver.py +27 -0
- pal/tool_manager/components/param_validator.py +41 -0
- pal/tool_manager/components/tool_call_queue.py +111 -0
- pal/tool_manager/components/tool_registry.py +60 -0
- pal/tool_manager/pipeline.py +80 -0
- pal/tool_manager/utils/__init__.py +3 -0
- pal/web/__init__.py +3 -0
- pal/web/app.py +276 -0
- pal/web/static/editor.js +219 -0
- pal/web/static/fonts/LICENSE-Inter.txt +3 -0
- pal/web/static/fonts/inter-400.woff2 +0 -0
- pal/web/static/fonts/inter-500.woff2 +0 -0
- pal/web/static/fonts/inter-600.woff2 +0 -0
- pal/web/static/fonts/inter-700.woff2 +0 -0
- pal/web/static/inter.css +5 -0
- pal/web/static/pal-logo-256.png +0 -0
- pal/web/static/pal-logo-32.png +0 -0
- pal/web/static/pal-logo-64.png +0 -0
- pal/web/static/studio.css +306 -0
- pal/web/static/studio.js +533 -0
- pal/web/static/voice.js +76 -0
- pal/web/templates/index.html +368 -0
- pal/web/templates/studio.html +120 -0
- pal_code-0.1.0.dist-info/METADATA +379 -0
- pal_code-0.1.0.dist-info/RECORD +282 -0
- pal_code-0.1.0.dist-info/WHEEL +5 -0
- pal_code-0.1.0.dist-info/entry_points.txt +2 -0
- pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- pal_code-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""The OS kernel interface: where non-LLM queries go.
|
|
2
|
+
|
|
3
|
+
An agent's non-LLM requests (file operations, network requests, system facts) are not model work and never touch the
|
|
4
|
+
LLM. They are system calls into the operating system, here Ubuntu's Linux kernel: files through its filesystem, requests
|
|
5
|
+
through its network stack, facts through /proc. This class is the guarded doorway: a per-agent workspace, size and
|
|
6
|
+
network limits, the access manager's confirmation gate for destructive operations, and an audit trail (operation and
|
|
7
|
+
target only, never file contents).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import logging
|
|
13
|
+
from collections import deque
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from pal.access_manager.components.base import OperationType
|
|
18
|
+
from pal.access_manager.pipeline import AccessManagerPipeline
|
|
19
|
+
from pal.config import OsKernelConfig
|
|
20
|
+
from pal.os_kernel import network
|
|
21
|
+
from pal.os_kernel.filesystem import OSKernelError, Workspace
|
|
22
|
+
from pal.os_kernel.sysinfo import system_info
|
|
23
|
+
from pal.paths import data_dir, safe_id
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
OS_OPS = ("file_read", "file_write", "file_list", "file_delete", "file_read_b64", "net_get", "net_post", "net_request", "sys_info",
|
|
28
|
+
"keyboard", "mouse")
|
|
29
|
+
|
|
30
|
+
# the file extension for a downloaded media type, used when a caller saves a response as "name.{ext}"
|
|
31
|
+
_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif", "audio/flac": "flac",
|
|
32
|
+
"audio/x-flac": "flac", "audio/wav": "wav", "audio/x-wav": "wav", "audio/wave": "wav", "audio/mpeg": "mp3",
|
|
33
|
+
"audio/ogg": "ogg", "audio/mp4": "m4a"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OSKernel:
|
|
37
|
+
def __init__(self, cfg: OsKernelConfig | dict | None = None, access: AccessManagerPipeline | None = None,
|
|
38
|
+
root: Path | None = None):
|
|
39
|
+
self.cfg = OsKernelConfig.coerce(cfg)
|
|
40
|
+
self.access = access or AccessManagerPipeline()
|
|
41
|
+
self.root = Path(root) if root else (Path(self.cfg.workspace_dir).expanduser() if self.cfg.workspace_dir
|
|
42
|
+
else data_dir("workspaces"))
|
|
43
|
+
self._workspaces: dict[str, Workspace] = {}
|
|
44
|
+
self.audit: deque[dict] = deque(maxlen=500)
|
|
45
|
+
|
|
46
|
+
def workspace(self, agent_id: str) -> Workspace:
|
|
47
|
+
key = safe_id(agent_id)
|
|
48
|
+
if key not in self._workspaces:
|
|
49
|
+
self._workspaces[key] = Workspace(self.root / key, self.cfg.max_read_bytes, self.cfg.max_write_bytes,
|
|
50
|
+
self.cfg.max_binary_bytes)
|
|
51
|
+
return self._workspaces[key]
|
|
52
|
+
|
|
53
|
+
def drop_workspace(self, agent_id: str) -> bool:
|
|
54
|
+
"""Delete an agent's whole workspace: used when a chat session ends, so uploaded and generated files do not outlive it."""
|
|
55
|
+
import shutil
|
|
56
|
+
key = safe_id(agent_id)
|
|
57
|
+
self._workspaces.pop(key, None)
|
|
58
|
+
path = self.root / key
|
|
59
|
+
existed = path.is_dir()
|
|
60
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
61
|
+
return existed
|
|
62
|
+
|
|
63
|
+
def prune_stale(self, prefix: str, max_age_seconds: float) -> int:
|
|
64
|
+
"""Delete workspaces whose folder name starts with `prefix` and that nothing has touched for `max_age_seconds` (left behind
|
|
65
|
+
by a crash or an upload whose chat never started). Other workspaces, such as an SDK agent's own, are not touched."""
|
|
66
|
+
import shutil
|
|
67
|
+
import time
|
|
68
|
+
removed = 0
|
|
69
|
+
if not self.root.is_dir():
|
|
70
|
+
return 0
|
|
71
|
+
for d in self.root.iterdir():
|
|
72
|
+
try:
|
|
73
|
+
if d.is_dir() and d.name.startswith(prefix) and time.time() - self._last_touched(d) > max_age_seconds:
|
|
74
|
+
self._workspaces.pop(d.name, None)
|
|
75
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
76
|
+
removed += 1
|
|
77
|
+
except OSError:
|
|
78
|
+
continue
|
|
79
|
+
return removed
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _last_touched(d: Path) -> float:
|
|
83
|
+
times = [d.stat().st_mtime] + [p.stat().st_mtime for p in d.rglob("*") if p.is_file()]
|
|
84
|
+
return max(times)
|
|
85
|
+
|
|
86
|
+
def _authorize(self, agent_id: str, op: OperationType, resource: str) -> None:
|
|
87
|
+
decision = self.access.authorize(agent_id, agent_id, op, resource=resource)
|
|
88
|
+
if not decision.allowed:
|
|
89
|
+
raise OSKernelError(f"{op.value} of {resource!r} was not permitted: {decision.reason or 'denied'}")
|
|
90
|
+
|
|
91
|
+
def handle(self, agent_id: str, op: str, params: dict | None = None) -> Any:
|
|
92
|
+
params = params or {}
|
|
93
|
+
# a URL is logged without its query string: that is where a service key may be
|
|
94
|
+
target = network.safe_target(str(params["url"])) if params.get("url") else str(params.get("path") or params.get("prefix") or "")
|
|
95
|
+
ok = False
|
|
96
|
+
try:
|
|
97
|
+
if not self.cfg.enabled:
|
|
98
|
+
raise OSKernelError("OS access is turned off in the settings")
|
|
99
|
+
if op not in OS_OPS:
|
|
100
|
+
raise OSKernelError(f"unknown OS operation {op!r}; available: {', '.join(OS_OPS)}")
|
|
101
|
+
result = self._dispatch(agent_id, op, params)
|
|
102
|
+
ok = True
|
|
103
|
+
return result
|
|
104
|
+
finally:
|
|
105
|
+
self.audit.append({"agent": agent_id, "op": op, "target": target[:200], "ok": ok})
|
|
106
|
+
log.info("os call agent=%s op=%s target=%s ok=%s", agent_id, op, target[:120], ok)
|
|
107
|
+
|
|
108
|
+
def _dispatch(self, agent_id: str, op: str, p: dict) -> Any:
|
|
109
|
+
if op in ("keyboard", "mouse"):
|
|
110
|
+
raise OSKernelError("keyboard and mouse control need a desktop session and are not supported here")
|
|
111
|
+
if op == "sys_info":
|
|
112
|
+
return system_info()
|
|
113
|
+
if op in ("net_get", "net_post"):
|
|
114
|
+
return network.request(
|
|
115
|
+
"GET" if op == "net_get" else "POST", str(p.get("url", "")), allow_private=self.cfg.allow_private_network,
|
|
116
|
+
allowed_hosts=list(self.cfg.allowed_hosts), timeout=self.cfg.net_timeout_seconds,
|
|
117
|
+
max_bytes=self.cfg.net_max_bytes, json=p.get("json"))
|
|
118
|
+
if op == "net_request":
|
|
119
|
+
return self._net_request(agent_id, p)
|
|
120
|
+
ws = self.workspace(agent_id)
|
|
121
|
+
if op == "file_list":
|
|
122
|
+
return ws.list(str(p.get("prefix", "")))
|
|
123
|
+
path = str(p.get("path", ""))
|
|
124
|
+
ws.resolve(path) # validate first: nobody is ever asked to confirm an invalid path
|
|
125
|
+
if op == "file_read":
|
|
126
|
+
return ws.read(path)
|
|
127
|
+
if op == "file_read_b64":
|
|
128
|
+
data = ws.read_bytes(path)
|
|
129
|
+
return {"path": path, "bytes": len(data), "b64": base64.b64encode(data).decode("ascii")}
|
|
130
|
+
if op == "file_write":
|
|
131
|
+
self._authorize(agent_id, OperationType.OVERWRITE if ws.exists(path) else OperationType.WRITE, path)
|
|
132
|
+
return {"path": path, "bytes": ws.write(path, p.get("content", ""))}
|
|
133
|
+
if not ws.exists(path): # file_delete: nothing to confirm if there is nothing to delete
|
|
134
|
+
raise OSKernelError(f"no such file: {path}")
|
|
135
|
+
self._authorize(agent_id, OperationType.DELETE, path)
|
|
136
|
+
ws.delete(path)
|
|
137
|
+
return {"path": path, "deleted": True}
|
|
138
|
+
|
|
139
|
+
def _net_request(self, agent_id: str, p: dict) -> dict:
|
|
140
|
+
"""A network request with auth headers (from an allow-list), and binary data in either direction through the workspace:
|
|
141
|
+
`body_path` sends a workspace file as the request body, `save_as` stores a non-text answer in the workspace (a literal
|
|
142
|
+
"{ext}" in the name becomes the right extension). Text answers come back as `text`."""
|
|
143
|
+
method = str(p.get("method", "GET")).upper()
|
|
144
|
+
if method not in ("GET", "POST"):
|
|
145
|
+
raise OSKernelError("only GET and POST requests are supported")
|
|
146
|
+
ws = self.workspace(agent_id)
|
|
147
|
+
headers = network.clean_headers(p.get("headers"))
|
|
148
|
+
data = None
|
|
149
|
+
if p.get("body_path"):
|
|
150
|
+
if p.get("json") is not None:
|
|
151
|
+
raise OSKernelError("send either a JSON body or a file, not both")
|
|
152
|
+
data = ws.read_bytes(str(p["body_path"]))
|
|
153
|
+
headers["Content-Type"] = str(p.get("content_type") or "application/octet-stream")
|
|
154
|
+
headers = network.clean_headers(headers)
|
|
155
|
+
save_as = str(p["save_as"]) if p.get("save_as") else ""
|
|
156
|
+
if save_as:
|
|
157
|
+
ws.resolve(save_as.replace("{ext}", "bin")) # validate the name before spending a request
|
|
158
|
+
res = network.request(method, str(p.get("url", "")), allow_private=self.cfg.allow_private_network,
|
|
159
|
+
allowed_hosts=list(self.cfg.allowed_hosts), timeout=self.cfg.net_timeout_seconds,
|
|
160
|
+
max_bytes=self.cfg.net_max_bytes, json=p.get("json"), headers=headers, data=data,
|
|
161
|
+
max_binary_bytes=self.cfg.max_binary_bytes, keep_binary=True)
|
|
162
|
+
body = res.pop("body", None)
|
|
163
|
+
if body is None:
|
|
164
|
+
return res
|
|
165
|
+
res["binary"] = True
|
|
166
|
+
if res["truncated"]:
|
|
167
|
+
raise OSKernelError(f"the answer is larger than the {self.cfg.max_binary_bytes:,}-byte limit")
|
|
168
|
+
if not save_as or res["status"] >= 400:
|
|
169
|
+
return {**res, "saved": ""}
|
|
170
|
+
ext = _EXT.get(res["content_type"].split(";")[0].strip().lower(), "bin")
|
|
171
|
+
path = save_as.replace("{ext}", ext)
|
|
172
|
+
ws.resolve(path)
|
|
173
|
+
self._authorize(agent_id, OperationType.OVERWRITE if ws.exists(path) else OperationType.WRITE, path)
|
|
174
|
+
ws.write_bytes(path, body)
|
|
175
|
+
return {**res, "saved": path}
|
pal/os_kernel/sysinfo.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Read-only facts from the OS kernel. On Linux (Ubuntu) they come from /proc and /etc/os-release."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _distro() -> str:
|
|
10
|
+
try:
|
|
11
|
+
for line in Path("/etc/os-release").read_text(encoding="utf-8").splitlines():
|
|
12
|
+
if line.startswith("PRETTY_NAME="):
|
|
13
|
+
return line.split("=", 1)[1].strip().strip('"')
|
|
14
|
+
except OSError:
|
|
15
|
+
pass
|
|
16
|
+
return platform.system()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _meminfo() -> dict:
|
|
20
|
+
out = {}
|
|
21
|
+
try:
|
|
22
|
+
for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines():
|
|
23
|
+
key, _, rest = line.partition(":")
|
|
24
|
+
if key in ("MemTotal", "MemAvailable"):
|
|
25
|
+
out[key] = int(rest.split()[0]) // 1024
|
|
26
|
+
except (OSError, ValueError, IndexError):
|
|
27
|
+
pass
|
|
28
|
+
return {"total_mb": out.get("MemTotal"), "available_mb": out.get("MemAvailable")} if out else {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def system_info() -> dict:
|
|
32
|
+
info: dict = {"system": platform.system(), "distro": _distro(), "kernel": platform.release(), "machine": platform.machine(),
|
|
33
|
+
"cpu_count": os.cpu_count(), "memory": _meminfo()}
|
|
34
|
+
if hasattr(os, "getloadavg"):
|
|
35
|
+
info["load_average"] = [round(x, 2) for x in os.getloadavg()]
|
|
36
|
+
return info
|
pal/paths.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Where Pal keeps its data, the same way on Linux, macOS and Windows.
|
|
2
|
+
|
|
3
|
+
Default is `~/.pal` (Path.home() resolves correctly on every OS). Set PAL_HOME to move it.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_WINDOWS_RESERVED = {"con", "prn", "aux", "nul", *(f"com{i}" for i in range(1, 10)), *(f"lpt{i}" for i in range(1, 10))}
|
|
12
|
+
_SAFE_ID = re.compile(r"[^A-Za-z0-9_-]")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def pal_home() -> Path:
|
|
16
|
+
env = os.environ.get("PAL_HOME")
|
|
17
|
+
return Path(env).expanduser() if env else Path.home() / ".pal"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def data_dir(*parts: str) -> Path:
|
|
21
|
+
return pal_home().joinpath(*parts)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def safe_id(value: str, default: str = "default", max_len: int = 64) -> str:
|
|
25
|
+
"""Turn any string into something safe as one path component on every OS."""
|
|
26
|
+
out = _SAFE_ID.sub("", value or "")[:max_len]
|
|
27
|
+
if not out or out.lower() in _WINDOWS_RESERVED:
|
|
28
|
+
return default if not out else f"_{out}"
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_safe_relative_key(key: str) -> bool:
|
|
33
|
+
"""True when `key` is a relative path that stays inside its folder on every OS: no `..`, no drive
|
|
34
|
+
letters or absolute paths, no backslashes, no characters Windows forbids, no reserved device names."""
|
|
35
|
+
if not key or key != key.strip() or "\\" in key or key.startswith("/") or "\x00" in key:
|
|
36
|
+
return False
|
|
37
|
+
for part in key.split("/"):
|
|
38
|
+
if part in ("", ".", "..") or re.search(r'[<>:"|?*\x00-\x1f]', part) or part.endswith((".", " ")):
|
|
39
|
+
return False
|
|
40
|
+
if part.split(".")[0].lower() in _WINDOWS_RESERVED:
|
|
41
|
+
return False
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def atomic_write_bytes(path: Path, data: bytes) -> None:
|
|
46
|
+
"""Write via a temp file and rename, so a crash never leaves a half-written file (os.replace is
|
|
47
|
+
atomic on POSIX and replaces an existing file on Windows)."""
|
|
48
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
50
|
+
try:
|
|
51
|
+
tmp.write_bytes(data)
|
|
52
|
+
os.replace(tmp, path)
|
|
53
|
+
finally:
|
|
54
|
+
if tmp.exists():
|
|
55
|
+
tmp.unlink(missing_ok=True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def atomic_write_text(path: Path, text: str) -> None:
|
|
59
|
+
atomic_write_bytes(path, text.encode("utf-8"))
|
pal/pipeline.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
from pal.agent_constraints.pipeline import AgentConstraintsPipeline
|
|
5
|
+
from pal.compression.pipeline import CompressionPipeline
|
|
6
|
+
from pal.context_manager.components.pager import Segment
|
|
7
|
+
from pal.context_manager.pipeline import ContextManagerPipeline
|
|
8
|
+
from pal.llm_wrapper.components.base import LLMSyscall, WrapperConfig
|
|
9
|
+
from pal.llm_wrapper.pipeline import LLMWrapperPipeline
|
|
10
|
+
from pal.memory.pipeline import MemoryPipeline
|
|
11
|
+
from pal.metrics.pipeline import MetricsPipeline
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Pipeline:
|
|
15
|
+
"""
|
|
16
|
+
One full chat turn, top to bottom:
|
|
17
|
+
user message -> compression + memory recall -> cache + paging ->
|
|
18
|
+
an LLM core (any deployment option) -> compressed reply, with
|
|
19
|
+
metrics recorded and history/memory updated.
|
|
20
|
+
|
|
21
|
+
This is the class every other entry point (webapp, orchestrator
|
|
22
|
+
agents, experiments) drives -- it's the composition root for every
|
|
23
|
+
pal/<component>/pipeline.py above.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
core_config: WrapperConfig,
|
|
29
|
+
session_id: str = None,
|
|
30
|
+
memory_store_path: str = None,
|
|
31
|
+
syscall_scheduler=None,
|
|
32
|
+
agent_name: str = None,
|
|
33
|
+
storage_manager=None,
|
|
34
|
+
):
|
|
35
|
+
self.llm_wrapper = LLMWrapperPipeline(core_config)
|
|
36
|
+
self.context_manager = ContextManagerPipeline(self.llm_wrapper.core)
|
|
37
|
+
self.compression = CompressionPipeline()
|
|
38
|
+
self.agent_constraints = AgentConstraintsPipeline()
|
|
39
|
+
self.metrics = MetricsPipeline()
|
|
40
|
+
self.history: list[Segment] = []
|
|
41
|
+
self.session_id = session_id or str(uuid.uuid4())
|
|
42
|
+
self.agent_name = agent_name or self.session_id
|
|
43
|
+
|
|
44
|
+
# Persistent, cross-session memory (survives restarts) -- distinct
|
|
45
|
+
# from context_manager's pager, which only pages within THIS open
|
|
46
|
+
# conversation and forgets everything once the process exits.
|
|
47
|
+
# When a shared StorageManagerPipeline is supplied (AIOS 3.6 --
|
|
48
|
+
# see pal/storage_manager/), the memory manager's disk
|
|
49
|
+
# swaps route through it, scoped by this Pipeline's agent_name;
|
|
50
|
+
# otherwise MemoryPipeline falls back to its own private,
|
|
51
|
+
# unscoped local store.
|
|
52
|
+
self.storage_manager = storage_manager
|
|
53
|
+
self.memory = MemoryPipeline(
|
|
54
|
+
store_path=memory_store_path,
|
|
55
|
+
agent_id=self.agent_name,
|
|
56
|
+
storage_manager=storage_manager,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Optional centralized syscall scheduler (AIOS 3.3 -- see
|
|
60
|
+
# orchestrator/components/syscall_scheduler.py). When one is
|
|
61
|
+
# supplied (typically the same SyscallScheduler an
|
|
62
|
+
# OrchestratorPipeline owns, shared across every agent/pipeline
|
|
63
|
+
# in the system), this Pipeline submits its LLM requests there
|
|
64
|
+
# instead of calling the core directly -- so ordering and
|
|
65
|
+
# time-slicing across MULTIPLE pipelines/agents actually goes
|
|
66
|
+
# through one queue, not a private one per Pipeline instance.
|
|
67
|
+
# Left as None by default (e.g. the standalone webapp use case),
|
|
68
|
+
# in which case run() falls back to calling llm_wrapper directly.
|
|
69
|
+
self.syscall_scheduler = syscall_scheduler
|
|
70
|
+
|
|
71
|
+
# kept for callers that reached into the old Router/adapter directly
|
|
72
|
+
# (e.g. the webapp's naive-vs-optimized benchmark path)
|
|
73
|
+
@property
|
|
74
|
+
def core(self):
|
|
75
|
+
return self.llm_wrapper.core
|
|
76
|
+
|
|
77
|
+
def run(self, system_prompt: str, user_message: str, **kwargs) -> str:
|
|
78
|
+
# Permanent output-behavior constraints, merged into every system
|
|
79
|
+
# prompt so the model generates tersely up front rather than
|
|
80
|
+
# being trimmed after the fact.
|
|
81
|
+
system_prompt = self.agent_constraints.with_constraints(system_prompt)
|
|
82
|
+
|
|
83
|
+
compressed_msg = self.compression.compress_input(user_message)
|
|
84
|
+
|
|
85
|
+
# Persistent memory recall: pull relevant notes from PAST
|
|
86
|
+
# sessions (not this conversation's own history). Injected into
|
|
87
|
+
# the system prompt so it doesn't distort the naive-vs-optimized
|
|
88
|
+
# token comparison for history paging below.
|
|
89
|
+
recalled_memories = self.memory.retrieve(compressed_msg, top_k=3)
|
|
90
|
+
if recalled_memories:
|
|
91
|
+
memory_block = "\n".join(f"- {n.content}" for n in recalled_memories)
|
|
92
|
+
system_prompt = f"{system_prompt}\n\nRelevant memory from past sessions:\n{memory_block}"
|
|
93
|
+
|
|
94
|
+
cache_hit, _ = self.context_manager.cache_system_prompt(system_prompt)
|
|
95
|
+
paged = self.context_manager.page_history(self.history, compressed_msg)
|
|
96
|
+
|
|
97
|
+
messages = [{"role": "system", "content": system_prompt}]
|
|
98
|
+
for seg in paged:
|
|
99
|
+
messages.append({"role": seg.role, "content": seg.content})
|
|
100
|
+
messages.append({"role": "user", "content": compressed_msg})
|
|
101
|
+
|
|
102
|
+
# Naive baseline: what a naive/unbounded implementation would
|
|
103
|
+
# send this turn -- the FULL history (not paged/trimmed) plus
|
|
104
|
+
# system prompt and new message. Comparing it against what was
|
|
105
|
+
# actually sent (optimized_tokens below) reflects real paging
|
|
106
|
+
# savings, not a same-payload measured-two-ways artifact.
|
|
107
|
+
naive_messages = [system_prompt] + [seg.content for seg in self.history] + [compressed_msg]
|
|
108
|
+
naive_tokens = sum(self.llm_wrapper.syscall_token_count(t) for t in naive_messages)
|
|
109
|
+
|
|
110
|
+
t0 = time.time()
|
|
111
|
+
response = self._dispatch_chat(messages, **kwargs)
|
|
112
|
+
latency = time.time() - t0
|
|
113
|
+
|
|
114
|
+
output_text = self.compression.compress_output(response.text)
|
|
115
|
+
|
|
116
|
+
# Optimized figure: the server's own reported prompt-eval count,
|
|
117
|
+
# when available. For local server cores this reflects tokens
|
|
118
|
+
# actually reprocessed -- if the backend's KV cache reused a
|
|
119
|
+
# matching prefix, this number comes back lower than the naive
|
|
120
|
+
# estimate on its own. Falls back to the estimate otherwise
|
|
121
|
+
# (meaning: no measurable saving, not a caching failure).
|
|
122
|
+
optimized_tokens = response.prompt_tokens if response.prompt_tokens else naive_tokens
|
|
123
|
+
|
|
124
|
+
self.metrics.record(
|
|
125
|
+
tokens_naive=naive_tokens,
|
|
126
|
+
tokens_optimized=optimized_tokens,
|
|
127
|
+
latency=latency,
|
|
128
|
+
cache_hit=cache_hit,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
self.history.append(Segment(id=str(len(self.history)), content=compressed_msg, role="user"))
|
|
132
|
+
self.history.append(Segment(id=str(len(self.history)), content=output_text, role="assistant"))
|
|
133
|
+
|
|
134
|
+
# Persist this turn to long-term memory so it can be recalled in
|
|
135
|
+
# a FUTURE session (a future Pipeline instance, possibly after a
|
|
136
|
+
# restart) -- not just later in this same conversation.
|
|
137
|
+
self.memory.add(compressed_msg, session_id=self.session_id, tags=["user_turn"])
|
|
138
|
+
|
|
139
|
+
return output_text
|
|
140
|
+
|
|
141
|
+
def _dispatch_chat(self, messages: list[dict], **kwargs):
|
|
142
|
+
"""Routes the actual inference call either through the shared
|
|
143
|
+
centralized SyscallScheduler (when one was supplied -- request
|
|
144
|
+
ordering/time-slicing then happens there, alongside every other
|
|
145
|
+
pipeline/agent sharing that scheduler) or directly through
|
|
146
|
+
llm_wrapper when running standalone."""
|
|
147
|
+
if self.syscall_scheduler is None:
|
|
148
|
+
return self.llm_wrapper.syscall_chat(messages, **kwargs)
|
|
149
|
+
|
|
150
|
+
syscall = LLMSyscall(
|
|
151
|
+
messages=messages,
|
|
152
|
+
model=kwargs.pop("model", None),
|
|
153
|
+
temperature=kwargs.pop("temperature", None),
|
|
154
|
+
max_tokens=kwargs.pop("max_tokens", None),
|
|
155
|
+
stream=kwargs.pop("stream", False),
|
|
156
|
+
params=kwargs,
|
|
157
|
+
)
|
|
158
|
+
request_id = self.syscall_scheduler.submit(self.agent_name, self.llm_wrapper.core, syscall)
|
|
159
|
+
|
|
160
|
+
# Drive the shared scheduler until THIS request reaches a
|
|
161
|
+
# terminal state. Other pipelines/agents sharing the same
|
|
162
|
+
# scheduler may have requests interleaved into these ticks too
|
|
163
|
+
# (that's the point of centralizing) -- this loop just doesn't
|
|
164
|
+
# return control to this Pipeline until its own turn is done.
|
|
165
|
+
req = self.syscall_scheduler.get_result(request_id)
|
|
166
|
+
while req.status.value in ("queued", "running"):
|
|
167
|
+
self.syscall_scheduler.tick()
|
|
168
|
+
req = self.syscall_scheduler.get_result(request_id)
|
|
169
|
+
|
|
170
|
+
if req.status.value == "error":
|
|
171
|
+
raise RuntimeError(f"syscall failed: {req.error}")
|
|
172
|
+
return req.result
|
|
173
|
+
|
|
174
|
+
def summary(self) -> dict:
|
|
175
|
+
"""Metrics summary including cache memory overhead, so savings
|
|
176
|
+
can be checked against the cost of the caching layer itself."""
|
|
177
|
+
return self.metrics.summary(cache_overhead_bytes=self.context_manager.size_bytes())
|
pal/py.typed
ADDED
|
File without changes
|
pal/redact.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Hide API keys in any text that may be shown or logged. Known keys are replaced exactly; strings shaped like common provider
|
|
2
|
+
keys are replaced too, because a provider's error message can echo a key we were never told about."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_SECRET = re.compile(r"(sk-[A-Za-z0-9_\-]{8,}|AIza[0-9A-Za-z_\-]{10,}|AQ\.[A-Za-z0-9_\-]{20,}|AKIA[0-9A-Z]{12,}"
|
|
8
|
+
r"|Bearer\s+[A-Za-z0-9._\-]{12,})")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# a key=value pair whose name says it is a secret, as in a query string: ?api_key=abc&q=cats
|
|
12
|
+
_PAIR = re.compile(r"(?i)\b(api[_-]?key|apikey|appid|app_id|access[_-]?token|auth[_-]?token|token|secret|password|key)=([^&\s\"']+)")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def redact(text: str, *keys: str | None) -> str:
|
|
16
|
+
for k in keys:
|
|
17
|
+
if k:
|
|
18
|
+
text = text.replace(k, "***")
|
|
19
|
+
return _PAIR.sub(r"\1=***", _SECRET.sub("***", text))
|
pal/sdk/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The Pal SDK: build native agents on pre-defined APIs, or adapt agents written with other frameworks.
|
|
2
|
+
|
|
3
|
+
from pal.sdk import AgentSDK, adapt, NativeAgent, ReActAgent, ReflexionAgent
|
|
4
|
+
"""
|
|
5
|
+
from pal.sdk.adapters import adapt, supported_frameworks
|
|
6
|
+
from pal.sdk.agent import AgentResult, AgentSpec, NativeAgent, agent_key, list_agents
|
|
7
|
+
from pal.sdk.components.autogen_adapter import AutoGenAdapter
|
|
8
|
+
from pal.sdk.components.autogen_client import autogen_model_client
|
|
9
|
+
from pal.sdk.components.framework_adapter_base import FrameworkAdapter, FrameworkAdapterError
|
|
10
|
+
from pal.sdk.components.metagpt_adapter import MetaGPTAdapter
|
|
11
|
+
from pal.sdk.components.open_interpreter_adapter import OpenInterpreterAdapter
|
|
12
|
+
from pal.sdk.components.tool_catalog import register_builtin_tools
|
|
13
|
+
from pal.sdk.os_api import OSAPI, register_os_tools
|
|
14
|
+
from pal.sdk.patterns import ReActAgent, ReflexionAgent
|
|
15
|
+
from pal.sdk.pipeline import AgentSDK
|
|
16
|
+
from pal.sdk.queries import (
|
|
17
|
+
OS_KERNEL,
|
|
18
|
+
PAL_KERNEL,
|
|
19
|
+
LLMQuery,
|
|
20
|
+
MemoryQuery,
|
|
21
|
+
OSQuery,
|
|
22
|
+
Query,
|
|
23
|
+
Response,
|
|
24
|
+
StorageQuery,
|
|
25
|
+
ToolQuery,
|
|
26
|
+
)
|
|
27
|
+
from pal.sdk.savers import CallSavers
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AgentResult", "AgentSDK", "LLMQuery", "PAL_KERNEL", "MemoryQuery", "OSAPI", "OSQuery", "OS_KERNEL", "Query", "Response", "StorageQuery", "ToolQuery", "register_os_tools", "AgentSpec", "AutoGenAdapter", "CallSavers", "FrameworkAdapter",
|
|
31
|
+
"FrameworkAdapterError", "MetaGPTAdapter", "NativeAgent", "OpenInterpreterAdapter", "ReActAgent",
|
|
32
|
+
"ReflexionAgent", "adapt", "agent_key", "autogen_model_client", "list_agents", "register_builtin_tools", "supported_frameworks",
|
|
33
|
+
]
|
pal/sdk/adapters.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""One entry point for non-native agents: `adapt("autogen", sdk)`.
|
|
2
|
+
|
|
3
|
+
Third-party frameworks are adapted by replacing the framework's own core model-call function so the call goes to
|
|
4
|
+
the SDK instead; the agent's code is not changed. ReAct and Reflexion are reasoning patterns, not libraries, so
|
|
5
|
+
they ship as native agents (`ReActAgent`, `ReflexionAgent`).
|
|
6
|
+
|
|
7
|
+
sdk = AgentSDK.standalone(WrapperConfig(backend="gemini", model="...", api_key="..."))
|
|
8
|
+
with adapt("autogen", sdk):
|
|
9
|
+
... # your existing AutoGen code, unchanged
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pal.sdk.components.autogen_adapter import AutoGenAdapter
|
|
14
|
+
from pal.sdk.components.framework_adapter_base import FrameworkAdapter, FrameworkAdapterError
|
|
15
|
+
from pal.sdk.components.metagpt_adapter import MetaGPTAdapter
|
|
16
|
+
from pal.sdk.components.open_interpreter_adapter import OpenInterpreterAdapter
|
|
17
|
+
from pal.sdk.pipeline import AgentSDK
|
|
18
|
+
|
|
19
|
+
_ADAPTERS: dict[str, type[FrameworkAdapter]] = {
|
|
20
|
+
"autogen": AutoGenAdapter, "open-interpreter": OpenInterpreterAdapter, "metagpt": MetaGPTAdapter}
|
|
21
|
+
_ALIASES = {"open_interpreter": "open-interpreter", "interpreter": "open-interpreter", "pyautogen": "autogen"}
|
|
22
|
+
_PATTERNS = {"react": "ReActAgent", "reflexion": "ReflexionAgent"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _norm(name: str) -> str:
|
|
26
|
+
n = name.strip().lower()
|
|
27
|
+
return _ALIASES.get(n, n)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def adapt(framework: str, sdk: AgentSDK) -> FrameworkAdapter:
|
|
31
|
+
"""An adapter for `framework`. Use it as a context manager, or call install() and uninstall() yourself."""
|
|
32
|
+
n = _norm(framework)
|
|
33
|
+
if n in _PATTERNS:
|
|
34
|
+
raise FrameworkAdapterError(f"{framework} is a reasoning pattern, not a library: use {_PATTERNS[n]}(sdk) "
|
|
35
|
+
"from pal.sdk")
|
|
36
|
+
cls = _ADAPTERS.get(n)
|
|
37
|
+
if cls is None:
|
|
38
|
+
raise FrameworkAdapterError(f"no adapter for {framework!r}; supported: {', '.join(supported_frameworks())}")
|
|
39
|
+
return cls(sdk)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def supported_frameworks() -> list[str]:
|
|
43
|
+
return [*_ADAPTERS, *_PATTERNS]
|
pal/sdk/agent.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Native agent development: what a developer subclasses to build an agent for Pal.
|
|
2
|
+
|
|
3
|
+
A *native* agent is written for Pal: it talks to the system only through the SDK's pre-defined APIs
|
|
4
|
+
(`self.sdk.chat`, `.remember`, `.recall`, `.write`, `.read`, `.search_knowledge`, `.call_tool`, `.request_access`).
|
|
5
|
+
It never holds a model client, a file handle or a tool directly, so Pal can route, trim, cache, limit and
|
|
6
|
+
measure every call. A *non-native* agent is written with another framework and is adapted instead
|
|
7
|
+
(see `pal.sdk.adapt`).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, ClassVar, Protocol
|
|
15
|
+
|
|
16
|
+
from pal.errors import UserInputError
|
|
17
|
+
|
|
18
|
+
_READ_TOOLS = frozenset({"read_source", "search_documents", "read_document"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class AgentSpec:
|
|
23
|
+
"""The small, declarative part of an agent."""
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
name: str
|
|
27
|
+
description: str
|
|
28
|
+
role: str # kept short: it is sent with every request
|
|
29
|
+
key_env: str = "" # environment variable holding this agent's API key ("" = none needed)
|
|
30
|
+
tools: frozenset[str] = _READ_TOOLS
|
|
31
|
+
max_tokens: int = 1024 # output cap per call (free tiers are small)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class AgentResult:
|
|
36
|
+
answer: str
|
|
37
|
+
ok: bool = True
|
|
38
|
+
steps: list[Any] = field(default_factory=list) # ReAct steps, Reflexion trials, ...
|
|
39
|
+
error: str = ""
|
|
40
|
+
meta: dict = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Kernel(Protocol):
|
|
44
|
+
"""Anything that runs a full token-saving turn: ChatService does."""
|
|
45
|
+
|
|
46
|
+
def send(self, session_id: str, provider: str, model: str, api_key: str | None, message: str,
|
|
47
|
+
attachments: list[dict], *args: Any, **kw: Any) -> Any: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NativeAgent:
|
|
51
|
+
"""Base class for native agents.
|
|
52
|
+
|
|
53
|
+
`sdk` gives the pre-defined APIs. `kernel` (optional) is the token-saving turn pipeline; when present, the
|
|
54
|
+
default `run()` uses it, so the agent gets input trimming, document and code-graph retrieval, paged history
|
|
55
|
+
and output rules for free. Without a kernel, `run()` makes one plain SDK call with the agent's role.
|
|
56
|
+
Override `run()` to write your own logic with the SDK calls (see ReActAgent and ReflexionAgent).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
spec: ClassVar[AgentSpec | None] = None
|
|
60
|
+
|
|
61
|
+
def __init__(self, sdk: Any = None, kernel: Kernel | None = None):
|
|
62
|
+
self.sdk = sdk
|
|
63
|
+
self.kernel = kernel
|
|
64
|
+
|
|
65
|
+
def run(self, task: str, attachments: list[dict] | None = None, session: str = "agent") -> AgentResult:
|
|
66
|
+
spec = self.spec
|
|
67
|
+
if spec is None:
|
|
68
|
+
raise NotImplementedError("a native agent needs a `spec` or its own run()")
|
|
69
|
+
if self.kernel is not None:
|
|
70
|
+
res = self.kernel.send(session, "", "", None, task, list(attachments or []), agent=spec.id)
|
|
71
|
+
return AgentResult(res.reply, meta={"input_tokens": res.input_tokens, "output_tokens": res.output_tokens})
|
|
72
|
+
if self.sdk is None:
|
|
73
|
+
raise RuntimeError("no SDK or kernel: create the agent with sdk= or kernel=")
|
|
74
|
+
text = self.sdk.chat([{"role": "system", "content": spec.role}, {"role": "user", "content": task}],
|
|
75
|
+
max_tokens=spec.max_tokens)
|
|
76
|
+
return AgentResult(text)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def agent_key(spec: AgentSpec, env: Mapping[str, str] | None = None) -> str | None:
|
|
80
|
+
"""The agent's own key, else the shared GEMINI_API_KEY."""
|
|
81
|
+
env = os.environ if env is None else env
|
|
82
|
+
return (env.get(spec.key_env) if spec.key_env else None) or env.get("GEMINI_API_KEY") or None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_spec(agents: Mapping[str, type[NativeAgent]], agent_id: str) -> AgentSpec:
|
|
86
|
+
cls = agents.get(agent_id)
|
|
87
|
+
if cls is None or cls.spec is None:
|
|
88
|
+
raise UserInputError(f"unknown agent {agent_id!r}; choose one of: {', '.join(agents)}")
|
|
89
|
+
return cls.spec
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def list_agents(agents: Mapping[str, type[NativeAgent]], env: Mapping[str, str] | None = None) -> list[dict]:
|
|
93
|
+
out = []
|
|
94
|
+
for cls in agents.values():
|
|
95
|
+
s = cls.spec
|
|
96
|
+
if s is not None:
|
|
97
|
+
out.append({"id": s.id, "name": s.name, "description": s.description,
|
|
98
|
+
"ready": bool(agent_key(s, env)), "key_env": s.key_env})
|
|
99
|
+
return out
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pal.sdk.components.autogen_adapter import AutoGenAdapter
|
|
2
|
+
from pal.sdk.components.framework_adapter_base import FrameworkAdapter, FrameworkAdapterError
|
|
3
|
+
from pal.sdk.components.metagpt_adapter import MetaGPTAdapter
|
|
4
|
+
from pal.sdk.components.open_interpreter_adapter import OpenInterpreterAdapter
|
|
5
|
+
from pal.sdk.components.tool_catalog import register_builtin_tools
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AutoGenAdapter",
|
|
9
|
+
"FrameworkAdapter",
|
|
10
|
+
"FrameworkAdapterError",
|
|
11
|
+
"MetaGPTAdapter",
|
|
12
|
+
"OpenInterpreterAdapter",
|
|
13
|
+
"register_builtin_tools",
|
|
14
|
+
]
|