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,138 @@
|
|
|
1
|
+
"""The Notes Manager: everything about the notes the chat keeps across sessions, in one place.
|
|
2
|
+
|
|
3
|
+
* WHAT IS KEPT only messages you typed (never attachments, never model replies), at least `min_chars` long, and never anything that
|
|
4
|
+
looks like a secret (API keys, passwords, private keys).
|
|
5
|
+
* WHAT IS RECALLED notes from *other* sessions that match the current question, within a token budget, added to that one request.
|
|
6
|
+
* UPKEEP old notes beyond a cap are pruned; duplicate notes are merged in the background (no model, an OTHER-class job nobody
|
|
7
|
+
waits for), every `tidy_every` turns.
|
|
8
|
+
* CONTROL count, clear, forget one session's notes, and the RAM / disk numbers behind them.
|
|
9
|
+
|
|
10
|
+
It sits on the Memory Manager (RAM cache, LRU-K eviction) which sits on the Storage Manager (the disk). It owns its own lock, so callers never
|
|
11
|
+
have to. Nothing here leaves the machine except the recalled notes that go to the API model inside a request.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import threading
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
from pal.config import MemoryConfig
|
|
20
|
+
from pal.llm_scheduler.types import TaskClass
|
|
21
|
+
from pal.notes_manager.components.privacy import looks_secret
|
|
22
|
+
from pal.notes_manager.components.recall import Recall, build_recall
|
|
23
|
+
from pal.notes_manager.components.tidy import tidy_steps
|
|
24
|
+
from pal.tokens import estimate_tokens
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger(__name__)
|
|
27
|
+
_MAX_NOTES_PRUNE = 50
|
|
28
|
+
|
|
29
|
+
__all__ = ["NotesManager", "Recall", "looks_secret"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NotesManager:
|
|
33
|
+
def __init__(self, sdk, cfg: MemoryConfig | dict | None = None, count: Callable[[str], int] = estimate_tokens,
|
|
34
|
+
max_notes: int = 1000, scheduler=None):
|
|
35
|
+
self.sdk = sdk
|
|
36
|
+
self.cfg = MemoryConfig.coerce(cfg)
|
|
37
|
+
self.count = count
|
|
38
|
+
self.max_notes = max_notes
|
|
39
|
+
self.scheduler = scheduler
|
|
40
|
+
self._lock = threading.RLock()
|
|
41
|
+
self._new_notes = 0
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def enabled(self) -> bool:
|
|
45
|
+
return self.cfg.enabled
|
|
46
|
+
|
|
47
|
+
def attach_scheduler(self, scheduler) -> None:
|
|
48
|
+
"""The scheduler that runs the background tidy-up (it needs a local line to run on)."""
|
|
49
|
+
self.scheduler = scheduler
|
|
50
|
+
|
|
51
|
+
# -- keeping and recalling --------------------------------------------------------------------------------------------
|
|
52
|
+
def remember(self, session_id: str, message: str) -> bool:
|
|
53
|
+
text = message.strip()
|
|
54
|
+
if not self.enabled or len(text) < self.cfg.min_chars or looks_secret(text):
|
|
55
|
+
return False
|
|
56
|
+
try:
|
|
57
|
+
with self._lock:
|
|
58
|
+
self.sdk.remember(text[:1000], tags=["user"], session_id=session_id)
|
|
59
|
+
self._prune()
|
|
60
|
+
except Exception:
|
|
61
|
+
log.exception("memory write failed") # notes are best-effort: never fail the chat
|
|
62
|
+
return False
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
def recall(self, session_id: str, query: str) -> Recall | None:
|
|
66
|
+
if not self.enabled or not self.cfg.recall_notes or not query.strip():
|
|
67
|
+
return None
|
|
68
|
+
try:
|
|
69
|
+
with self._lock:
|
|
70
|
+
notes = self.sdk.memory.retrieve(query, top_k=self.cfg.recall_notes * 3, min_score=self.cfg.min_score)
|
|
71
|
+
except Exception:
|
|
72
|
+
log.exception("memory recall failed")
|
|
73
|
+
return None
|
|
74
|
+
return build_recall(notes, session_id, self.cfg.recall_notes, self.cfg.budget_tokens, self.count)
|
|
75
|
+
|
|
76
|
+
# -- control ------------------------------------------------------------------------------------------------------------
|
|
77
|
+
def count_notes(self) -> int:
|
|
78
|
+
with self._lock:
|
|
79
|
+
return len(self.sdk.memory)
|
|
80
|
+
|
|
81
|
+
__len__ = count_notes
|
|
82
|
+
|
|
83
|
+
def clear(self) -> int:
|
|
84
|
+
with self._lock:
|
|
85
|
+
n = len(self.sdk.memory)
|
|
86
|
+
self.sdk.memory.clear()
|
|
87
|
+
self._new_notes = 0
|
|
88
|
+
return n
|
|
89
|
+
|
|
90
|
+
def forget_session(self, session_id: str) -> int:
|
|
91
|
+
"""Delete the notes that one session wrote. Returns how many."""
|
|
92
|
+
with self._lock:
|
|
93
|
+
mine = [n for n in self.sdk.memory.storage.read_all() if n.session_id == session_id]
|
|
94
|
+
for n in mine:
|
|
95
|
+
self.sdk.memory.manager.delete(n.id)
|
|
96
|
+
return len(mine)
|
|
97
|
+
|
|
98
|
+
def stats(self) -> dict:
|
|
99
|
+
"""How many notes there are, how they split between RAM and disk, how full the RAM cache is, and how many are new since the last tidy."""
|
|
100
|
+
with self._lock:
|
|
101
|
+
return {"enabled": self.enabled, "notes": len(self.sdk.memory), "since_tidy": self._new_notes, "max_notes": self.max_notes, **self.sdk.memory.stats()}
|
|
102
|
+
|
|
103
|
+
# -- upkeep -----------------------------------------------------------------------------------------------------------
|
|
104
|
+
def tidy_steps(self, near: float = 0.9, batch: int = 100):
|
|
105
|
+
"""Merge duplicate and near-duplicate notes (see components/tidy.py). A generator, so a scheduler can pause it."""
|
|
106
|
+
return tidy_steps(self.sdk.memory, self._lock, near, batch)
|
|
107
|
+
|
|
108
|
+
def tidy(self) -> int:
|
|
109
|
+
"""Run tidy_steps to the end. Returns how many notes were removed."""
|
|
110
|
+
gen = self.tidy_steps()
|
|
111
|
+
while True:
|
|
112
|
+
try:
|
|
113
|
+
next(gen)
|
|
114
|
+
except StopIteration as done:
|
|
115
|
+
return int(done.value or 0)
|
|
116
|
+
|
|
117
|
+
def after_turn(self, ephemeral: bool = False) -> None:
|
|
118
|
+
"""Called after each chat turn. Every `tidy_every` turns it hands the tidy-up to the scheduler as a background job."""
|
|
119
|
+
local = getattr(self.scheduler, "local", None)
|
|
120
|
+
if ephemeral or local is None:
|
|
121
|
+
return
|
|
122
|
+
try:
|
|
123
|
+
every = self.cfg.tidy_every
|
|
124
|
+
self._new_notes += 1
|
|
125
|
+
if not every or not self.enabled or self._new_notes < every:
|
|
126
|
+
return
|
|
127
|
+
self._new_notes = 0
|
|
128
|
+
local.submit_steps(self.tidy_steps(), TaskClass.OTHER, "tidy memory")
|
|
129
|
+
except Exception:
|
|
130
|
+
log.exception("background upkeep could not be scheduled") # never fail the chat because of housekeeping
|
|
131
|
+
|
|
132
|
+
def _prune(self) -> None:
|
|
133
|
+
over = len(self.sdk.memory) - self.max_notes
|
|
134
|
+
if over <= 0:
|
|
135
|
+
return
|
|
136
|
+
notes = sorted(self.sdk.memory.storage.read_all(), key=lambda n: (n.retrieval_count, n.timestamp))
|
|
137
|
+
for n in notes[: min(over + _MAX_NOTES_PRUNE, len(notes))]:
|
|
138
|
+
self.sdk.memory.manager.delete(n.id)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from pal.orchestrator.components.agent import Agent, AgentConfig
|
|
2
|
+
from pal.orchestrator.components.cgroup_manager import (
|
|
3
|
+
CgroupError,
|
|
4
|
+
CgroupManager,
|
|
5
|
+
NullResourceManager,
|
|
6
|
+
ResourceLimits,
|
|
7
|
+
create_resource_manager,
|
|
8
|
+
)
|
|
9
|
+
from pal.orchestrator.components.context_interrupt import ContextInterruptController, InterruptResult
|
|
10
|
+
from pal.orchestrator.components.scheduler import ScheduledAgent, Scheduler
|
|
11
|
+
from pal.orchestrator.components.syscall_scheduler import (
|
|
12
|
+
SchedulingStrategy,
|
|
13
|
+
SyscallRequest,
|
|
14
|
+
SyscallScheduler,
|
|
15
|
+
SyscallStatus,
|
|
16
|
+
)
|
|
17
|
+
from pal.orchestrator.pipeline import OrchestratorPipeline
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"OrchestratorPipeline",
|
|
21
|
+
"Agent", "AgentConfig",
|
|
22
|
+
"CgroupManager", "CgroupError", "NullResourceManager", "ResourceLimits", "create_resource_manager",
|
|
23
|
+
"Scheduler", "ScheduledAgent",
|
|
24
|
+
"ContextInterruptController", "InterruptResult",
|
|
25
|
+
"SchedulingStrategy", "SyscallRequest", "SyscallScheduler", "SyscallStatus",
|
|
26
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from pal.orchestrator.components import limits
|
|
2
|
+
from pal.orchestrator.components.agent import Agent, AgentConfig
|
|
3
|
+
from pal.orchestrator.components.cgroup_manager import (
|
|
4
|
+
CgroupError,
|
|
5
|
+
CgroupManager,
|
|
6
|
+
NullResourceManager,
|
|
7
|
+
ResourceLimits,
|
|
8
|
+
create_resource_manager,
|
|
9
|
+
)
|
|
10
|
+
from pal.orchestrator.components.context_interrupt import ContextInterruptController, InterruptResult
|
|
11
|
+
from pal.orchestrator.components.scheduler import ScheduledAgent, Scheduler
|
|
12
|
+
from pal.orchestrator.components.syscall_scheduler import (
|
|
13
|
+
SchedulingStrategy,
|
|
14
|
+
SyscallRequest,
|
|
15
|
+
SyscallScheduler,
|
|
16
|
+
SyscallStatus,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Agent", "AgentConfig",
|
|
21
|
+
"CgroupManager", "CgroupError", "NullResourceManager", "ResourceLimits", "create_resource_manager",
|
|
22
|
+
"Scheduler", "ScheduledAgent",
|
|
23
|
+
"ContextInterruptController", "InterruptResult",
|
|
24
|
+
"SchedulingStrategy", "SyscallRequest", "SyscallScheduler", "SyscallStatus",
|
|
25
|
+
"limits",
|
|
26
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import multiprocessing as mp
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from pal.orchestrator.components.cgroup_manager import CgroupManager, ResourceLimits
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _agent_worker(backend: str, model: str, host: str, task_q: mp.Queue, result_q: mp.Queue):
|
|
8
|
+
"""
|
|
9
|
+
Runs inside the child process. Imports pal lazily, here, not
|
|
10
|
+
at module level -- the parent process doesn't need pal
|
|
11
|
+
importable to manage cgroups/scheduling, only the child that
|
|
12
|
+
actually runs a Pipeline does. Keeps the orchestrator's own
|
|
13
|
+
dependency footprint minimal.
|
|
14
|
+
"""
|
|
15
|
+
from pal.llm_wrapper.components.base import WrapperConfig
|
|
16
|
+
from pal.pipeline import Pipeline
|
|
17
|
+
|
|
18
|
+
pipeline = Pipeline(WrapperConfig(backend=backend, model=model, host=host))
|
|
19
|
+
|
|
20
|
+
while True:
|
|
21
|
+
task = task_q.get()
|
|
22
|
+
if task is None:
|
|
23
|
+
break
|
|
24
|
+
system_prompt, user_message = task
|
|
25
|
+
try:
|
|
26
|
+
result = pipeline.run(system_prompt, user_message)
|
|
27
|
+
result_q.put(("ok", result, pipeline.summary()))
|
|
28
|
+
except Exception as e:
|
|
29
|
+
result_q.put(("error", str(e), None))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class AgentConfig:
|
|
34
|
+
name: str
|
|
35
|
+
backend: str = "ollama"
|
|
36
|
+
model: str = "llama3"
|
|
37
|
+
host: str = "http://localhost:11434"
|
|
38
|
+
limits: ResourceLimits = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Agent:
|
|
42
|
+
"""
|
|
43
|
+
One agent = one real OS process running a Pal Pipeline, bound
|
|
44
|
+
to its own cgroup slice. The cgroup enforces the resource envelope;
|
|
45
|
+
the process boundary is what makes that enforcement meaningful
|
|
46
|
+
(unlike an in-process object, which shares the parent's resource
|
|
47
|
+
pool).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, config: AgentConfig, cgroup_manager: CgroupManager):
|
|
51
|
+
self.config = config
|
|
52
|
+
self.cgroup_manager = cgroup_manager
|
|
53
|
+
self.process: mp.Process | None = None
|
|
54
|
+
self.task_q: mp.Queue = mp.Queue()
|
|
55
|
+
self.result_q: mp.Queue = mp.Queue()
|
|
56
|
+
self._started = False
|
|
57
|
+
|
|
58
|
+
def start(self) -> None:
|
|
59
|
+
if self._started:
|
|
60
|
+
raise RuntimeError(f"agent {self.config.name} already started")
|
|
61
|
+
|
|
62
|
+
limits = self.config.limits or ResourceLimits(memory_max="512M", pids_max="50")
|
|
63
|
+
self.cgroup_manager.create_slice(self.config.name, limits)
|
|
64
|
+
|
|
65
|
+
self.process = mp.Process(
|
|
66
|
+
target=_agent_worker,
|
|
67
|
+
args=(self.config.backend, self.config.model, self.config.host, self.task_q, self.result_q),
|
|
68
|
+
daemon=True,
|
|
69
|
+
)
|
|
70
|
+
self.process.start()
|
|
71
|
+
|
|
72
|
+
# Assign immediately after spawn so the process's entire
|
|
73
|
+
# lifetime -- including model/library imports and any threads it
|
|
74
|
+
# spins up -- is subject to the cgroup's limits, not just the
|
|
75
|
+
# work it does after some later point.
|
|
76
|
+
self.cgroup_manager.assign_pid(self.config.name, self.process.pid)
|
|
77
|
+
self._started = True
|
|
78
|
+
|
|
79
|
+
def run_turn(self, system_prompt: str, user_message: str, timeout: float = 60) -> dict:
|
|
80
|
+
if not self._started:
|
|
81
|
+
raise RuntimeError(f"agent {self.config.name} not started")
|
|
82
|
+
|
|
83
|
+
self.task_q.put((system_prompt, user_message))
|
|
84
|
+
status, payload, summary = self.result_q.get(timeout=timeout)
|
|
85
|
+
|
|
86
|
+
if status == "error":
|
|
87
|
+
raise RuntimeError(f"agent {self.config.name} failed: {payload}")
|
|
88
|
+
|
|
89
|
+
return {"response": payload, "metrics": summary}
|
|
90
|
+
|
|
91
|
+
def stats(self) -> dict:
|
|
92
|
+
return self.cgroup_manager.stats(self.config.name)
|
|
93
|
+
|
|
94
|
+
def stop(self) -> None:
|
|
95
|
+
if not self._started:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
self.task_q.put(None)
|
|
99
|
+
self.process.join(timeout=5)
|
|
100
|
+
if self.process.is_alive():
|
|
101
|
+
self.process.terminate()
|
|
102
|
+
self.process.join(timeout=5)
|
|
103
|
+
|
|
104
|
+
self.cgroup_manager.delete_slice(self.config.name)
|
|
105
|
+
self._started = False
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
CGROUP_ROOT = Path("/sys/fs/cgroup")
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
_SLICE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
15
|
+
_LIMIT_VALUE = re.compile(r"^[0-9A-Za-z ]{1,32}$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _check_name(name: str) -> str:
|
|
19
|
+
"""Slice names become directory names under /sys/fs/cgroup and are used in privileged commands,
|
|
20
|
+
so only plain names are accepted."""
|
|
21
|
+
if not _SLICE_NAME.match(name or "") or name.endswith("."):
|
|
22
|
+
raise CgroupError(f"invalid slice name: {name!r}")
|
|
23
|
+
return name
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _check_value(value: str) -> str:
|
|
27
|
+
if not _LIMIT_VALUE.match(str(value)):
|
|
28
|
+
raise CgroupError(f"invalid limit value: {value!r}")
|
|
29
|
+
return str(value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ResourceLimits:
|
|
34
|
+
memory_max: str | None = None # e.g. "256M", "1G"
|
|
35
|
+
cpu_max: str | None = None # e.g. "50000 100000" = 50% of one CPU
|
|
36
|
+
pids_max: str | None = None # e.g. "50"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CgroupError(RuntimeError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CgroupManager:
|
|
44
|
+
"""
|
|
45
|
+
Creates and manages cgroup v2 slices, one per agent, under a single
|
|
46
|
+
parent slice (default: /sys/fs/cgroup/pal.slice/<agent_name>).
|
|
47
|
+
|
|
48
|
+
Requires root (via sudo) on this host -- WSL2's systemd user-session
|
|
49
|
+
delegation wasn't reliably attached to interactive shells here, so
|
|
50
|
+
operations run through `sudo` explicitly rather than assuming direct
|
|
51
|
+
write access. This is isolated behind this module so a future port
|
|
52
|
+
to a real delegated slice (or to a custom kernel) only changes
|
|
53
|
+
_run(), not any caller.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
enforced = True
|
|
57
|
+
|
|
58
|
+
def __init__(self, parent_slice: str = "pal2.slice"):
|
|
59
|
+
self.parent_path = CGROUP_ROOT / _check_name(parent_slice)
|
|
60
|
+
self._ensure_parent()
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def supported() -> bool:
|
|
64
|
+
"""Linux with cgroup v2 mounted, and either root or sudo to write to it."""
|
|
65
|
+
if not sys.platform.startswith("linux") or not (CGROUP_ROOT / "cgroup.controllers").exists():
|
|
66
|
+
return False
|
|
67
|
+
return _is_root() or shutil.which("sudo") is not None
|
|
68
|
+
|
|
69
|
+
def _privileged(self, cmd: list[str]) -> list[str]:
|
|
70
|
+
return cmd if _is_root() else ["sudo", *cmd]
|
|
71
|
+
|
|
72
|
+
def _run(self, cmd: list[str]) -> subprocess.CompletedProcess:
|
|
73
|
+
result = subprocess.run(self._privileged(cmd), capture_output=True, text=True)
|
|
74
|
+
if result.returncode != 0:
|
|
75
|
+
raise CgroupError(f"command failed: {' '.join(cmd)}\n{result.stderr}")
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
def _write(self, path: Path, value: str) -> None:
|
|
79
|
+
# value goes in on stdin to `tee`: no shell, so nothing in `value` or `path` can inject a command
|
|
80
|
+
result = subprocess.run(self._privileged(["tee", str(path)]), input=f"{value}\n", capture_output=True,
|
|
81
|
+
text=True)
|
|
82
|
+
if result.returncode != 0:
|
|
83
|
+
raise CgroupError(f"failed writing {value!r} to {path}: {result.stderr}")
|
|
84
|
+
|
|
85
|
+
def _ensure_parent(self) -> None:
|
|
86
|
+
if not self.parent_path.exists():
|
|
87
|
+
self._run(["mkdir", "-p", str(self.parent_path)])
|
|
88
|
+
|
|
89
|
+
# A cgroup only passes controllers down to its own children once
|
|
90
|
+
# they're explicitly enabled in *its* cgroup.subtree_control --
|
|
91
|
+
# inheriting them at the root level isn't enough. Without this,
|
|
92
|
+
# child slices exist as directories but reject writes to
|
|
93
|
+
# memory.max / cpu.max / pids.max with Permission denied.
|
|
94
|
+
root_subtree = CGROUP_ROOT / "cgroup.subtree_control"
|
|
95
|
+
if root_subtree.exists():
|
|
96
|
+
enabled = root_subtree.read_text(encoding="utf-8").split()
|
|
97
|
+
missing = [c for c in ("cpu", "memory", "pids") if c not in enabled]
|
|
98
|
+
if missing:
|
|
99
|
+
flags = " ".join(f"+{c}" for c in missing)
|
|
100
|
+
self._write(root_subtree, flags)
|
|
101
|
+
|
|
102
|
+
parent_subtree = self.parent_path / "cgroup.subtree_control"
|
|
103
|
+
if parent_subtree.exists():
|
|
104
|
+
enabled = parent_subtree.read_text(encoding="utf-8").split()
|
|
105
|
+
missing = [c for c in ("cpu", "memory", "pids") if c not in enabled]
|
|
106
|
+
if missing:
|
|
107
|
+
flags = " ".join(f"+{c}" for c in missing)
|
|
108
|
+
self._write(parent_subtree, flags)
|
|
109
|
+
|
|
110
|
+
def create_slice(self, agent_name: str, limits: ResourceLimits) -> Path:
|
|
111
|
+
slice_path = self.parent_path / _check_name(agent_name)
|
|
112
|
+
if slice_path.exists():
|
|
113
|
+
raise CgroupError(f"slice already exists: {agent_name}")
|
|
114
|
+
|
|
115
|
+
self._run(["mkdir", "-p", str(slice_path)])
|
|
116
|
+
|
|
117
|
+
if limits.memory_max:
|
|
118
|
+
self._write(slice_path / "memory.max", _check_value(limits.memory_max))
|
|
119
|
+
if limits.cpu_max:
|
|
120
|
+
self._write(slice_path / "cpu.max", _check_value(limits.cpu_max))
|
|
121
|
+
if limits.pids_max:
|
|
122
|
+
self._write(slice_path / "pids.max", _check_value(limits.pids_max))
|
|
123
|
+
|
|
124
|
+
return slice_path
|
|
125
|
+
|
|
126
|
+
def assign_pid(self, agent_name: str, pid: int) -> None:
|
|
127
|
+
slice_path = self.parent_path / agent_name
|
|
128
|
+
if not slice_path.exists():
|
|
129
|
+
raise CgroupError(f"no such slice: {agent_name}")
|
|
130
|
+
self._write(slice_path / "cgroup.procs", str(pid))
|
|
131
|
+
|
|
132
|
+
def current_pids(self, agent_name: str) -> list[int]:
|
|
133
|
+
slice_path = self.parent_path / agent_name
|
|
134
|
+
procs_file = slice_path / "cgroup.procs"
|
|
135
|
+
if not procs_file.exists():
|
|
136
|
+
raise CgroupError(f"no such slice: {agent_name}")
|
|
137
|
+
content = procs_file.read_text(encoding="utf-8").strip()
|
|
138
|
+
return [int(p) for p in content.splitlines() if p]
|
|
139
|
+
|
|
140
|
+
def stats(self, agent_name: str) -> dict:
|
|
141
|
+
slice_path = self.parent_path / agent_name
|
|
142
|
+
stats = {}
|
|
143
|
+
for fname in ("memory.current", "memory.max", "pids.current"):
|
|
144
|
+
fpath = slice_path / fname
|
|
145
|
+
if fpath.exists():
|
|
146
|
+
stats[fname] = fpath.read_text(encoding="utf-8").strip()
|
|
147
|
+
return stats
|
|
148
|
+
|
|
149
|
+
def delete_slice(self, agent_name: str) -> None:
|
|
150
|
+
slice_path = self.parent_path / agent_name
|
|
151
|
+
if not slice_path.exists():
|
|
152
|
+
return
|
|
153
|
+
# cgroups can only be removed once empty of processes.
|
|
154
|
+
pids = self.current_pids(agent_name)
|
|
155
|
+
if pids:
|
|
156
|
+
raise CgroupError(f"cannot delete slice {agent_name}: still has PIDs {pids}")
|
|
157
|
+
self._run(["rmdir", str(slice_path)])
|
|
158
|
+
|
|
159
|
+
def list_slices(self) -> list[str]:
|
|
160
|
+
if not self.parent_path.exists():
|
|
161
|
+
return []
|
|
162
|
+
return [p.name for p in self.parent_path.iterdir() if p.is_dir()]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _is_root() -> bool:
|
|
166
|
+
return hasattr(os, "geteuid") and os.geteuid() == 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class NullResourceManager:
|
|
170
|
+
"""Used where cgroups are unavailable (macOS, Windows, Linux without cgroup v2 or sudo). Agents still
|
|
171
|
+
run as separate processes, but memory/CPU/process limits are NOT enforced. The orchestrator logs
|
|
172
|
+
that once, at construction."""
|
|
173
|
+
|
|
174
|
+
enforced = False
|
|
175
|
+
|
|
176
|
+
def __init__(self, parent_slice: str = "pal.slice"):
|
|
177
|
+
self.parent_slice = parent_slice
|
|
178
|
+
self._slices: dict[str, ResourceLimits] = {}
|
|
179
|
+
|
|
180
|
+
def create_slice(self, agent_name: str, limits: ResourceLimits):
|
|
181
|
+
if agent_name in self._slices:
|
|
182
|
+
raise CgroupError(f"slice already exists: {agent_name}")
|
|
183
|
+
self._slices[agent_name] = limits
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
def assign_pid(self, agent_name: str, pid: int) -> None:
|
|
187
|
+
if agent_name not in self._slices:
|
|
188
|
+
raise CgroupError(f"no such slice: {agent_name}")
|
|
189
|
+
|
|
190
|
+
def current_pids(self, agent_name: str) -> list[int]:
|
|
191
|
+
return []
|
|
192
|
+
|
|
193
|
+
def stats(self, agent_name: str) -> dict:
|
|
194
|
+
return {"enforced": False}
|
|
195
|
+
|
|
196
|
+
def delete_slice(self, agent_name: str) -> None:
|
|
197
|
+
self._slices.pop(agent_name, None)
|
|
198
|
+
|
|
199
|
+
def list_slices(self) -> list[str]:
|
|
200
|
+
return sorted(self._slices)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def create_resource_manager(parent_slice: str = "pal.slice", manager_cls=CgroupManager):
|
|
204
|
+
"""A real cgroup manager where the OS supports it, otherwise a no-op one (with a warning)."""
|
|
205
|
+
if manager_cls is not CgroupManager or CgroupManager.supported():
|
|
206
|
+
return manager_cls(parent_slice)
|
|
207
|
+
log.warning("cgroup v2 is not available here (needs Linux with cgroup v2 and root or sudo): agent "
|
|
208
|
+
"resource limits will NOT be enforced")
|
|
209
|
+
return NullResourceManager(parent_slice)
|