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.
Files changed (282) hide show
  1. pal/__init__.py +29 -0
  2. pal/__main__.py +3 -0
  3. pal/access_manager/__init__.py +16 -0
  4. pal/access_manager/components/__init__.py +14 -0
  5. pal/access_manager/components/access_control.py +41 -0
  6. pal/access_manager/components/base.py +39 -0
  7. pal/access_manager/components/intervention.py +31 -0
  8. pal/access_manager/components/privilege_registry.py +30 -0
  9. pal/access_manager/pipeline.py +68 -0
  10. pal/access_manager/utils/__init__.py +2 -0
  11. pal/agent_constraints/__init__.py +3 -0
  12. pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
  13. pal/agent_constraints/components/__init__.py +3 -0
  14. pal/agent_constraints/components/constraints.py +22 -0
  15. pal/agent_constraints/pipeline.py +22 -0
  16. pal/agent_constraints/utils/__init__.py +2 -0
  17. pal/agents/__init__.py +12 -0
  18. pal/agents/coding.py +14 -0
  19. pal/agents/docs.py +15 -0
  20. pal/chat/__init__.py +4 -0
  21. pal/chat/demo.py +231 -0
  22. pal/chat/providers.py +23 -0
  23. pal/chat/service.py +591 -0
  24. pal/chat/services/__init__.py +26 -0
  25. pal/chat/services/base.py +142 -0
  26. pal/chat/services/data.py +142 -0
  27. pal/chat/services/media.py +123 -0
  28. pal/chat/services/web.py +60 -0
  29. pal/chat/sessions.py +108 -0
  30. pal/chat/tools.py +446 -0
  31. pal/chat/uploads.py +81 -0
  32. pal/cli.py +502 -0
  33. pal/compression/__init__.py +12 -0
  34. pal/compression/components/__init__.py +64 -0
  35. pal/compression/components/adverbs.py +20 -0
  36. pal/compression/components/hedges.py +54 -0
  37. pal/compression/components/interjections.py +20 -0
  38. pal/compression/components/minifier.py +56 -0
  39. pal/compression/components/output_compressor.py +13 -0
  40. pal/compression/components/pleonasms.py +54 -0
  41. pal/compression/components/punctuations.py +36 -0
  42. pal/compression/components/stopwords.py +62 -0
  43. pal/compression/history.py +346 -0
  44. pal/compression/pipeline.py +58 -0
  45. pal/compression/utils/__init__.py +30 -0
  46. pal/compression/utils/negations.py +18 -0
  47. pal/compression/utils/nltk_setup.py +20 -0
  48. pal/compression/utils/pronouns.py +27 -0
  49. pal/compression/utils/proselint_setup.py +9 -0
  50. pal/compression/utils/spacy_setup.py +22 -0
  51. pal/config/default.yaml +163 -0
  52. pal/config.py +504 -0
  53. pal/context_manager/__init__.py +7 -0
  54. pal/context_manager/chat_context.py +117 -0
  55. pal/context_manager/components/__init__.py +5 -0
  56. pal/context_manager/components/assembler.py +75 -0
  57. pal/context_manager/components/context_cache.py +45 -0
  58. pal/context_manager/components/kv_bridge.py +16 -0
  59. pal/context_manager/components/pager.py +48 -0
  60. pal/context_manager/components/simple_context_manager.py +56 -0
  61. pal/context_manager/components/turns.py +88 -0
  62. pal/context_manager/pipeline.py +27 -0
  63. pal/context_manager/utils/__init__.py +18 -0
  64. pal/context_manager/utils/hashing.py +5 -0
  65. pal/context_manager/utils/relevance.py +59 -0
  66. pal/context_manager/utils/storage.py +37 -0
  67. pal/documents/__init__.py +4 -0
  68. pal/documents/components/__init__.py +0 -0
  69. pal/documents/components/chunker.py +79 -0
  70. pal/documents/components/index.py +155 -0
  71. pal/documents/utils/__init__.py +1 -0
  72. pal/errors.py +40 -0
  73. pal/input_converters/__init__.py +8 -0
  74. pal/input_converters/components/__init__.py +0 -0
  75. pal/input_converters/components/base.py +38 -0
  76. pal/input_converters/components/pandoc_converter.py +87 -0
  77. pal/input_converters/components/pdf_converter.py +46 -0
  78. pal/input_converters/components/pptx_converter.py +52 -0
  79. pal/input_converters/components/xlsx_converter.py +38 -0
  80. pal/input_converters/pipeline.py +72 -0
  81. pal/input_converters/utils/__init__.py +1 -0
  82. pal/input_converters/workspace.py +56 -0
  83. pal/input_savers/INPUT_RULES.md +2 -0
  84. pal/input_savers/__init__.py +5 -0
  85. pal/input_savers/components/__init__.py +0 -0
  86. pal/input_savers/components/base.py +40 -0
  87. pal/input_savers/components/methods.py +41 -0
  88. pal/input_savers/components/verifier.py +39 -0
  89. pal/input_savers/pipeline.py +154 -0
  90. pal/input_savers/utils/__init__.py +0 -0
  91. pal/input_savers/utils/guards.py +94 -0
  92. pal/knowledge_graph/__init__.py +10 -0
  93. pal/knowledge_graph/components/__init__.py +0 -0
  94. pal/knowledge_graph/components/base.py +50 -0
  95. pal/knowledge_graph/components/builder.py +54 -0
  96. pal/knowledge_graph/components/retriever.py +100 -0
  97. pal/knowledge_graph/components/workspace.py +116 -0
  98. pal/knowledge_graph/pipeline.py +215 -0
  99. pal/knowledge_graph/utils/__init__.py +1 -0
  100. pal/latency_tracker/__init__.py +3 -0
  101. pal/latency_tracker/pipeline.py +86 -0
  102. pal/llm_scheduler/__init__.py +11 -0
  103. pal/llm_scheduler/api.py +137 -0
  104. pal/llm_scheduler/code.py +28 -0
  105. pal/llm_scheduler/core.py +92 -0
  106. pal/llm_scheduler/local.py +215 -0
  107. pal/llm_scheduler/pipeline.py +42 -0
  108. pal/llm_scheduler/types.py +92 -0
  109. pal/llm_wrapper/__init__.py +34 -0
  110. pal/llm_wrapper/adapter.py +316 -0
  111. pal/llm_wrapper/components/__init__.py +23 -0
  112. pal/llm_wrapper/components/adapter_types.py +90 -0
  113. pal/llm_wrapper/components/base.py +195 -0
  114. pal/llm_wrapper/components/features.py +104 -0
  115. pal/llm_wrapper/components/litellm_wrapper.py +215 -0
  116. pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
  117. pal/llm_wrapper/modes.py +73 -0
  118. pal/llm_wrapper/pipeline.py +61 -0
  119. pal/llm_wrapper/utils/__init__.py +4 -0
  120. pal/llm_wrapper/utils/registry.py +49 -0
  121. pal/llm_wrapper/utils/token_counting.py +10 -0
  122. pal/local_llm/__init__.py +5 -0
  123. pal/local_llm/components/__init__.py +0 -0
  124. pal/local_llm/components/huggingface_local.py +217 -0
  125. pal/local_llm/components/local_llm.py +60 -0
  126. pal/logging_setup.py +18 -0
  127. pal/memory/__init__.py +7 -0
  128. pal/memory/components/__init__.py +6 -0
  129. pal/memory/components/lru_k.py +56 -0
  130. pal/memory/components/manager.py +98 -0
  131. pal/memory/components/note.py +54 -0
  132. pal/memory/components/storage_manager.py +60 -0
  133. pal/memory/pipeline.py +122 -0
  134. pal/memory/utils/__init__.py +3 -0
  135. pal/memory/utils/keyword_overlap.py +3 -0
  136. pal/memory_tracker/__init__.py +3 -0
  137. pal/memory_tracker/components/__init__.py +0 -0
  138. pal/memory_tracker/components/process.py +56 -0
  139. pal/memory_tracker/pipeline.py +44 -0
  140. pal/metrics/__init__.py +4 -0
  141. pal/metrics/components/__init__.py +4 -0
  142. pal/metrics/components/collector.py +140 -0
  143. pal/metrics/components/exporter.py +28 -0
  144. pal/metrics/pipeline.py +49 -0
  145. pal/metrics/utils/__init__.py +2 -0
  146. pal/notes_manager/__init__.py +5 -0
  147. pal/notes_manager/components/__init__.py +0 -0
  148. pal/notes_manager/components/privacy.py +14 -0
  149. pal/notes_manager/components/recall.py +31 -0
  150. pal/notes_manager/components/tidy.py +28 -0
  151. pal/notes_manager/pipeline.py +138 -0
  152. pal/orchestrator/__init__.py +26 -0
  153. pal/orchestrator/components/__init__.py +26 -0
  154. pal/orchestrator/components/agent.py +105 -0
  155. pal/orchestrator/components/cgroup_manager.py +209 -0
  156. pal/orchestrator/components/context_interrupt.py +220 -0
  157. pal/orchestrator/components/limits.py +9 -0
  158. pal/orchestrator/components/scheduler.py +69 -0
  159. pal/orchestrator/components/syscall_scheduler.py +200 -0
  160. pal/orchestrator/pipeline.py +228 -0
  161. pal/orchestrator/utils/__init__.py +4 -0
  162. pal/orchestrator/utils/device.py +164 -0
  163. pal/orchestrator/utils/protocol.py +32 -0
  164. pal/os_kernel/__init__.py +5 -0
  165. pal/os_kernel/filesystem.py +80 -0
  166. pal/os_kernel/network.py +146 -0
  167. pal/os_kernel/pipeline.py +175 -0
  168. pal/os_kernel/sysinfo.py +36 -0
  169. pal/paths.py +59 -0
  170. pal/pipeline.py +177 -0
  171. pal/py.typed +0 -0
  172. pal/redact.py +19 -0
  173. pal/sdk/__init__.py +33 -0
  174. pal/sdk/adapters.py +43 -0
  175. pal/sdk/agent.py +99 -0
  176. pal/sdk/components/__init__.py +14 -0
  177. pal/sdk/components/autogen_adapter.py +65 -0
  178. pal/sdk/components/autogen_client.py +98 -0
  179. pal/sdk/components/framework_adapter_base.py +69 -0
  180. pal/sdk/components/metagpt_adapter.py +46 -0
  181. pal/sdk/components/open_interpreter_adapter.py +47 -0
  182. pal/sdk/components/tool_catalog.py +44 -0
  183. pal/sdk/os_api.py +84 -0
  184. pal/sdk/patterns.py +129 -0
  185. pal/sdk/pipeline.py +240 -0
  186. pal/sdk/queries.py +68 -0
  187. pal/sdk/savers.py +88 -0
  188. pal/sdk/utils/__init__.py +2 -0
  189. pal/storage_manager/__init__.py +17 -0
  190. pal/storage_manager/components/__init__.py +19 -0
  191. pal/storage_manager/components/base.py +50 -0
  192. pal/storage_manager/components/local_file_backend.py +84 -0
  193. pal/storage_manager/components/storage_queue.py +91 -0
  194. pal/storage_manager/components/vector_store_backend.py +122 -0
  195. pal/storage_manager/pipeline.py +153 -0
  196. pal/storage_manager/utils/__init__.py +4 -0
  197. pal/storage_manager/utils/memory_adapter.py +60 -0
  198. pal/storage_manager/utils/sdk.py +93 -0
  199. pal/studio/__init__.py +0 -0
  200. pal/studio/builder.py +166 -0
  201. pal/studio/catalog.py +90 -0
  202. pal/studio/checks.py +129 -0
  203. pal/studio/clients.py +6 -0
  204. pal/studio/editor.py +137 -0
  205. pal/studio/files.py +6 -0
  206. pal/studio/models.py +6 -0
  207. pal/studio/plan.py +149 -0
  208. pal/studio/planner.py +205 -0
  209. pal/studio/policy.py +69 -0
  210. pal/studio/routes.py +254 -0
  211. pal/studio/runs.py +210 -0
  212. pal/task_router/__init__.py +11 -0
  213. pal/task_router/components/__init__.py +0 -0
  214. pal/task_router/components/answerer.py +37 -0
  215. pal/task_router/components/base.py +34 -0
  216. pal/task_router/components/classifier.py +40 -0
  217. pal/task_router/components/gates.py +23 -0
  218. pal/task_router/pipeline.py +61 -0
  219. pal/task_router/utils/__init__.py +1 -0
  220. pal/terminal/__init__.py +1 -0
  221. pal/terminal/backend.py +141 -0
  222. pal/terminal/bench.py +173 -0
  223. pal/terminal/cli.py +826 -0
  224. pal/terminal/clients.py +170 -0
  225. pal/terminal/commands.py +401 -0
  226. pal/terminal/decide.py +242 -0
  227. pal/terminal/files.py +55 -0
  228. pal/terminal/flow_cli.py +439 -0
  229. pal/terminal/hardware.py +199 -0
  230. pal/terminal/history.py +88 -0
  231. pal/terminal/keys.py +83 -0
  232. pal/terminal/local_models.py +261 -0
  233. pal/terminal/logo.py +117 -0
  234. pal/terminal/models.py +99 -0
  235. pal/terminal/policy.py +54 -0
  236. pal/terminal/project.py +413 -0
  237. pal/terminal/review.py +65 -0
  238. pal/terminal/security.py +239 -0
  239. pal/terminal/session.py +503 -0
  240. pal/terminal/setup.py +354 -0
  241. pal/terminal/syntax.py +98 -0
  242. pal/terminal/theme.py +37 -0
  243. pal/terminal/ui.py +647 -0
  244. pal/terminal/voice.py +137 -0
  245. pal/terminal/workflow.py +624 -0
  246. pal/token_tracker/__init__.py +3 -0
  247. pal/token_tracker/pipeline.py +54 -0
  248. pal/tokens.py +21 -0
  249. pal/tool_catalog.py +107 -0
  250. pal/tool_manager/__init__.py +17 -0
  251. pal/tool_manager/builtin.py +82 -0
  252. pal/tool_manager/components/__init__.py +19 -0
  253. pal/tool_manager/components/base.py +53 -0
  254. pal/tool_manager/components/conflict_resolver.py +27 -0
  255. pal/tool_manager/components/param_validator.py +41 -0
  256. pal/tool_manager/components/tool_call_queue.py +111 -0
  257. pal/tool_manager/components/tool_registry.py +60 -0
  258. pal/tool_manager/pipeline.py +80 -0
  259. pal/tool_manager/utils/__init__.py +3 -0
  260. pal/web/__init__.py +3 -0
  261. pal/web/app.py +276 -0
  262. pal/web/static/editor.js +219 -0
  263. pal/web/static/fonts/LICENSE-Inter.txt +3 -0
  264. pal/web/static/fonts/inter-400.woff2 +0 -0
  265. pal/web/static/fonts/inter-500.woff2 +0 -0
  266. pal/web/static/fonts/inter-600.woff2 +0 -0
  267. pal/web/static/fonts/inter-700.woff2 +0 -0
  268. pal/web/static/inter.css +5 -0
  269. pal/web/static/pal-logo-256.png +0 -0
  270. pal/web/static/pal-logo-32.png +0 -0
  271. pal/web/static/pal-logo-64.png +0 -0
  272. pal/web/static/studio.css +306 -0
  273. pal/web/static/studio.js +533 -0
  274. pal/web/static/voice.js +76 -0
  275. pal/web/templates/index.html +368 -0
  276. pal/web/templates/studio.html +120 -0
  277. pal_code-0.1.0.dist-info/METADATA +379 -0
  278. pal_code-0.1.0.dist-info/RECORD +282 -0
  279. pal_code-0.1.0.dist-info/WHEEL +5 -0
  280. pal_code-0.1.0.dist-info/entry_points.txt +2 -0
  281. pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
  282. 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)