repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
Mirrored from https://github.com/yingqi-z20/Agent-libOS
stars
latest
clone command
git clone gitlawb://did:key:z6MkqRzA...RfoM/yingqi-z20-Agen...git clone gitlawb://did:key:z6MkqRzA.../yingqi-z20-Agen...d98dd2c9IPC1d ago| #1 | from __future__ import annotations |
| #2 | |
| #3 | import builtins |
| #4 | from collections.abc import Callable |
| #5 | from pathlib import PurePosixPath, PureWindowsPath |
| #6 | from typing import Any, Iterable |
| #7 | |
| #8 | from agent_libos.capability.manager import CapabilityManager |
| #9 | from agent_libos.config import DEFAULT_CONFIG, AgentLibOSConfig |
| #10 | from agent_libos.models.exceptions import CapabilityDenied, NotFound, ProcessError, ProcessWaitRequired |
| #11 | from agent_libos.utils.ids import new_id, utc_now |
| #12 | from agent_libos.memory.object_memory import ObjectMemoryManager |
| #13 | from agent_libos.models import ( |
| #14 | AgentProcess, |
| #15 | CapabilityRight, |
| #16 | EventType, |
| #17 | ForkMode, |
| #18 | MemoryView, |
| #19 | MemoryViewSpec, |
| #20 | MergePolicy, |
| #21 | MergeResult, |
| #22 | ObjectHandle, |
| #23 | ObjectMetadata, |
| #24 | ObjectType, |
| #25 | ProcessResult, |
| #26 | ProcessSignal, |
| #27 | ProcessStatus, |
| #28 | ResourceBudget, |
| #29 | ViewMode, |
| #30 | ) |
| #31 | from agent_libos.runtime.audit_manager import AuditManager |
| #32 | from agent_libos.runtime.event_bus import EventBus |
| #33 | from agent_libos.storage import SQLiteStore |
| #34 | |
| #35 | _RUNTIME_DEFAULTS = DEFAULT_CONFIG.runtime |
| #36 | |
| #37 | |
| #38 | class ProcessManager: |
| #39 | """Process lifecycle primitive.""" |
| #40 | |
| #41 | TERMINAL_STATUSES = {ProcessStatus.EXITED, ProcessStatus.FAILED, ProcessStatus.KILLED} |
| #42 | |
| #43 | def __init__( |
| #44 | self, |
| #45 | store: SQLiteStore, |
| #46 | memory: ObjectMemoryManager, |
| #47 | capabilities: CapabilityManager, |
| #48 | audit: AuditManager, |
| #49 | events: EventBus, |
| #50 | config: AgentLibOSConfig | None = None, |
| #51 | ): |
| #52 | self.config = config or DEFAULT_CONFIG |
| #53 | self.store = store |
| #54 | self.memory = memory |
| #55 | self.capabilities = capabilities |
| #56 | self.audit = audit |
| #57 | self.events = events |
| #58 | self._after_spawn_hooks: builtins.list[Callable[[str, str], None]] = [] |
| #59 | |
| #60 | def add_after_spawn_hook(self, hook: Callable[[str, str], None]) -> None: |
| #61 | self._after_spawn_hooks.append(hook) |
| #62 | |
| #63 | def spawn( |
| #64 | self, |
| #65 | image: str = _RUNTIME_DEFAULTS.default_image_id, |
| #66 | goal: dict[str, Any] | str | ObjectHandle | None = None, |
| #67 | capabilities: builtins.list[dict[str, Any]] | None = None, |
| #68 | resource_budget: ResourceBudget | None = None, |
| #69 | working_directory: str | None = None, |
| #70 | ) -> str: |
| #71 | now = utc_now() |
| #72 | pid = new_id("pid") |
| #73 | cwd = self._normalize_working_directory(working_directory or self.config.process.default_working_directory) |
| #74 | process = AgentProcess( |
| #75 | pid=pid, |
| #76 | parent_pid=None, |
| #77 | image_id=image, |
| #78 | status=ProcessStatus.CREATED, |
| #79 | goal_oid=None, |
| #80 | memory_view=None, |
| #81 | capabilities=[], |
| #82 | loaded_skills={}, |
| #83 | tool_table={}, |
| #84 | event_cursor=None, |
| #85 | checkpoint_head=None, |
| #86 | resource_budget=resource_budget or self._default_resource_budget(), |
| #87 | created_at=now, |
| #88 | updated_at=now, |
| #89 | working_directory=cwd, |
| #90 | ) |
| #91 | self.store.insert_process(process) |
| #92 | self.memory.ensure_process_namespace(pid) |
| #93 | goal_handle = self._ensure_goal(pid, goal) |
| #94 | # A process starts with a mutable view rooted at its goal. Later tool |
| #95 | # results are appended to this view by the LLM executor. |
| #96 | view = self.memory.create_view(pid, [goal_handle], mode=ViewMode.MUTABLE) |
| #97 | process.goal_oid = goal_handle.oid |
| #98 | process.memory_view = view |
| #99 | process.status = ProcessStatus.RUNNABLE |
| #100 | process.updated_at = utc_now() |
| #101 | self.store.update_process(process) |
| #102 | self._grant_specs(pid, capabilities or [], issued_by="process.spawn") |
| #103 | self.events.emit( |
| #104 | EventType.PROCESS_CREATED, |
| #105 | source="runtime", |
| #106 | target=pid, |
| #107 | payload={"pid": pid, "image": image, "goal_oid": goal_handle.oid, "working_directory": cwd}, |
| #108 | ) |
| #109 | self.audit.record( |
| #110 | actor="runtime", |
| #111 | action="process.spawn", |
| #112 | target=f"process:{pid}", |
| #113 | output_refs=[goal_handle.oid], |
| #114 | decision={"image": image, "working_directory": cwd}, |
| #115 | ) |
| #116 | self._run_after_spawn_hooks(pid, image) |
| #117 | return pid |
| #118 | |
| #119 | def fork( |
| #120 | self, |
| #121 | parent: str, |
| #122 | goal: dict[str, Any] | str | ObjectHandle, |
| #123 | memory_view: MemoryView | MemoryViewSpec | None = None, |
| #124 | capabilities: builtins.list[dict[str, Any]] | None = None, |
| #125 | inherit_capabilities: builtins.list[dict[str, Any]] | None = None, |
| #126 | image: str | None = None, |
| #127 | mode: ForkMode | str = ForkMode.RESTRICTED, |
| #128 | working_directory: str | None = None, |
| #129 | ) -> str: |
| #130 | parent_proc = self._get(parent) |
| #131 | fork_mode = ForkMode(mode) |
| #132 | if parent_proc.status in self.TERMINAL_STATUSES: |
| #133 | raise ProcessError(f"cannot fork terminated process: {parent}") |
| #134 | self._require_child_budget(parent_proc) |
| #135 | inherit_specs = inherit_capabilities or [] |
| #136 | self._validate_inherit_capability_specs(parent, inherit_specs) |
| #137 | cwd = self._normalize_working_directory(working_directory or parent_proc.working_directory) |
| #138 | now = utc_now() |
| #139 | child_pid = new_id("pid") |
| #140 | child = AgentProcess( |
| #141 | pid=child_pid, |
| #142 | parent_pid=parent, |
| #143 | image_id=image or parent_proc.image_id, |
| #144 | status=ProcessStatus.CREATED, |
| #145 | goal_oid=None, |
| #146 | memory_view=None, |
| #147 | capabilities=[], |
| #148 | loaded_skills=dict(parent_proc.loaded_skills), |
| #149 | tool_table={}, |
| #150 | event_cursor=None, |
| #151 | checkpoint_head=None, |
| #152 | resource_budget=ResourceBudget( |
| #153 | max_tool_calls=max( |
| #154 | self.config.process.fork_min_tool_calls, |
| #155 | parent_proc.resource_budget.max_tool_calls // self.config.process.fork_budget_divisor, |
| #156 | ), |
| #157 | max_child_processes=max( |
| #158 | self.config.process.fork_min_child_processes, |
| #159 | parent_proc.resource_budget.max_child_processes // self.config.process.fork_budget_divisor, |
| #160 | ), |
| #161 | max_runtime_seconds=parent_proc.resource_budget.max_runtime_seconds, |
| #162 | max_materialized_tokens=parent_proc.resource_budget.max_materialized_tokens, |
| #163 | ), |
| #164 | created_at=now, |
| #165 | updated_at=now, |
| #166 | working_directory=cwd, |
| #167 | ) |
| #168 | self.store.insert_process(child) |
| #169 | self.memory.ensure_process_namespace(child_pid, parent_pid=parent) |
| #170 | goal_handle = self._ensure_goal(child_pid, goal) |
| #171 | source_view = parent_proc.memory_view or self.memory.create_view(parent, [], mode=ViewMode.READ_ONLY) |
| #172 | if isinstance(memory_view, MemoryView): |
| #173 | source_view = memory_view |
| #174 | spec = MemoryViewSpec(mode=self._fork_mode_to_view_mode(fork_mode)) |
| #175 | else: |
| #176 | spec = memory_view or MemoryViewSpec(mode=self._fork_mode_to_view_mode(fork_mode)) |
| #177 | # Forking attenuates memory handles by default. The child can see only |
| #178 | # roots selected by the parent and only the rights granted into its view. |
| #179 | child_view = self.memory.fork_view(parent, child_pid, source_view, spec) |
| #180 | child_view.roots.append(goal_handle) |
| #181 | child.goal_oid = goal_handle.oid |
| #182 | child.memory_view = child_view |
| #183 | child.status = ProcessStatus.RUNNABLE |
| #184 | child.updated_at = utc_now() |
| #185 | self.store.update_process(child) |
| #186 | self._grant_specs(child_pid, capabilities or [], issued_by=f"process.fork:{parent}") |
| #187 | self._inherit_capability_specs( |
| #188 | parent_pid=parent, |
| #189 | child_pid=child_pid, |
| #190 | specs=inherit_specs, |
| #191 | issued_by=f"process.fork:{parent}", |
| #192 | ) |
| #193 | self.events.emit( |
| #194 | EventType.PROCESS_FORKED, |
| #195 | source=parent, |
| #196 | target=child_pid, |
| #197 | payload={"parent": parent, "child": child_pid, "mode": fork_mode.value, "working_directory": cwd}, |
| #198 | ) |
| #199 | self.audit.record( |
| #200 | actor=parent, |
| #201 | action="process.fork", |
| #202 | target=f"process:{child_pid}", |
| #203 | input_refs=[parent_proc.goal_oid] if parent_proc.goal_oid else [], |
| #204 | output_refs=[goal_handle.oid], |
| #205 | decision={"mode": fork_mode.value, "image": child.image_id, "working_directory": child.working_directory}, |
| #206 | ) |
| #207 | self._run_after_spawn_hooks(child_pid, child.image_id) |
| #208 | return child_pid |
| #209 | |
| #210 | def spawn_child( |
| #211 | self, |
| #212 | parent: str, |
| #213 | goal: dict[str, Any] | str | ObjectHandle, |
| #214 | capabilities: builtins.list[dict[str, Any]] | None = None, |
| #215 | inherit_capabilities: builtins.list[dict[str, Any]] | None = None, |
| #216 | image: str | None = None, |
| #217 | working_directory: str | None = None, |
| #218 | ) -> str: |
| #219 | parent_proc = self._get(parent) |
| #220 | if parent_proc.status in self.TERMINAL_STATUSES: |
| #221 | raise ProcessError(f"cannot spawn child from terminated process: {parent}") |
| #222 | self._require_child_budget(parent_proc) |
| #223 | inherit_specs = inherit_capabilities or [] |
| #224 | self._validate_inherit_capability_specs(parent, inherit_specs) |
| #225 | cwd = self._normalize_working_directory(working_directory or parent_proc.working_directory) |
| #226 | now = utc_now() |
| #227 | child_pid = new_id("pid") |
| #228 | child = AgentProcess( |
| #229 | pid=child_pid, |
| #230 | parent_pid=parent, |
| #231 | image_id=image or parent_proc.image_id, |
| #232 | status=ProcessStatus.CREATED, |
| #233 | goal_oid=None, |
| #234 | memory_view=None, |
| #235 | capabilities=[], |
| #236 | loaded_skills={}, |
| #237 | tool_table={}, |
| #238 | event_cursor=None, |
| #239 | checkpoint_head=None, |
| #240 | resource_budget=self._child_resource_budget(parent_proc), |
| #241 | created_at=now, |
| #242 | updated_at=now, |
| #243 | working_directory=cwd, |
| #244 | ) |
| #245 | self.store.insert_process(child) |
| #246 | self.memory.ensure_process_namespace(child_pid, parent_pid=parent) |
| #247 | goal_handle = self._ensure_goal(child_pid, goal) |
| #248 | # Unlike fork(), spawn_child() starts from a fresh address-space-like |
| #249 | # Object Memory view rooted only at the child goal. |
| #250 | child.memory_view = self.memory.create_view(child_pid, [goal_handle], mode=ViewMode.MUTABLE) |
| #251 | child.goal_oid = goal_handle.oid |
| #252 | child.status = ProcessStatus.RUNNABLE |
| #253 | child.updated_at = utc_now() |
| #254 | self.store.update_process(child) |
| #255 | self._grant_specs(child_pid, capabilities or [], issued_by=f"process.spawn_child:{parent}") |
| #256 | self._inherit_capability_specs( |
| #257 | parent_pid=parent, |
| #258 | child_pid=child_pid, |
| #259 | specs=inherit_specs, |
| #260 | issued_by=f"process.spawn_child:{parent}", |
| #261 | ) |
| #262 | self.events.emit( |
| #263 | EventType.PROCESS_CREATED, |
| #264 | source=parent, |
| #265 | target=child_pid, |
| #266 | payload={ |
| #267 | "parent": parent, |
| #268 | "child": child_pid, |
| #269 | "image": child.image_id, |
| #270 | "goal_oid": goal_handle.oid, |
| #271 | "working_directory": child.working_directory, |
| #272 | }, |
| #273 | ) |
| #274 | self.audit.record( |
| #275 | actor=parent, |
| #276 | action="process.spawn_child", |
| #277 | target=f"process:{child_pid}", |
| #278 | output_refs=[goal_handle.oid], |
| #279 | decision={"image": child.image_id, "working_directory": child.working_directory}, |
| #280 | ) |
| #281 | self._run_after_spawn_hooks(child_pid, child.image_id) |
| #282 | return child_pid |
| #283 | |
| #284 | def exec( |
| #285 | self, |
| #286 | pid: str, |
| #287 | image: str, |
| #288 | args: dict[str, Any] | None = None, |
| #289 | goal: dict[str, Any] | str | ObjectHandle | None = None, |
| #290 | preserve_memory: bool = True, |
| #291 | preserve_capabilities: bool = False, |
| #292 | ) -> None: |
| #293 | process = self._get(pid) |
| #294 | if process.status in self.TERMINAL_STATUSES: |
| #295 | raise ProcessError(f"cannot exec terminated process: {pid}") |
| #296 | old_image = process.image_id |
| #297 | goal_handle = self._ensure_goal(pid, goal) if goal is not None else None |
| #298 | process = self._get(pid) |
| #299 | if not preserve_capabilities: |
| #300 | kept: builtins.list[str] = [] |
| #301 | process_namespace_resource = f"object_namespace:{self.memory.process_namespace(pid)}" |
| #302 | for cap in self.capabilities.capabilities_for(pid): |
| #303 | if cap.resource.startswith("object:") or cap.resource == process_namespace_resource: |
| #304 | kept.append(cap.cap_id) |
| #305 | else: |
| #306 | self.capabilities.revoke(cap.cap_id, revoked_by="process.exec", reason="exec capability shrink") |
| #307 | process = self._get(pid) |
| #308 | process.capabilities = kept |
| #309 | if goal_handle is not None: |
| #310 | process.goal_oid = goal_handle.oid |
| #311 | if preserve_memory: |
| #312 | self._add_handle_to_process_view(process, goal_handle) |
| #313 | process = self._get(pid) |
| #314 | else: |
| #315 | process.memory_view = self.memory.create_view(pid, [goal_handle], mode=ViewMode.MUTABLE) |
| #316 | elif not preserve_memory: |
| #317 | process.memory_view = None |
| #318 | process.image_id = image |
| #319 | process.loaded_skills = {} |
| #320 | process.updated_at = utc_now() |
| #321 | self.store.update_process(process) |
| #322 | self.events.emit( |
| #323 | EventType.PROCESS_EXEC, |
| #324 | source=pid, |
| #325 | target=pid, |
| #326 | payload={ |
| #327 | "old_image": old_image, |
| #328 | "new_image": image, |
| #329 | "preserve_memory": preserve_memory, |
| #330 | "preserve_capabilities": preserve_capabilities, |
| #331 | "goal_oid": goal_handle.oid if goal_handle is not None else process.goal_oid, |
| #332 | "working_directory": process.working_directory, |
| #333 | }, |
| #334 | ) |
| #335 | self.audit.record( |
| #336 | actor=pid, |
| #337 | action="process.exec", |
| #338 | target=f"process:{pid}", |
| #339 | output_refs=[goal_handle.oid] if goal_handle is not None else [], |
| #340 | decision={ |
| #341 | "old_image": old_image, |
| #342 | "new_image": image, |
| #343 | "args": args or {}, |
| #344 | "goal_oid": goal_handle.oid if goal_handle is not None else process.goal_oid, |
| #345 | "preserve_memory": preserve_memory, |
| #346 | "preserve_capabilities": preserve_capabilities, |
| #347 | "working_directory": process.working_directory, |
| #348 | }, |
| #349 | ) |
| #350 | |
| #351 | def wait(self, pid: str, child: str, timeout: float | None = None) -> ProcessResult: |
| #352 | parent = self._get(pid) |
| #353 | child_proc = self._require_child(parent.pid, child) |
| #354 | if child_proc.status not in self.TERMINAL_STATUSES: |
| #355 | parent.status = ProcessStatus.WAITING_EVENT |
| #356 | parent.status_message = f"waiting for {child}" |
| #357 | parent.updated_at = utc_now() |
| #358 | self.store.update_process(parent) |
| #359 | if timeout == 0: |
| #360 | raise TimeoutError(f"child still running: {child}") |
| #361 | raise ProcessWaitRequired(child_pid=child, message=f"{pid} is waiting for child process {child}") |
| #362 | result_handle = None |
| #363 | if child_proc.status_message and child_proc.status_message.startswith("result_oid:"): |
| #364 | oid = child_proc.status_message.split(":", 1)[1] |
| #365 | result_handle = self.capabilities.handle_for_object( |
| #366 | pid, |
| #367 | oid, |
| #368 | {"read", "materialize", "link", "diff"}, |
| #369 | issued_by=f"process.wait:{child}", |
| #370 | ) |
| #371 | self._add_handle_to_process_view(parent, result_handle) |
| #372 | if parent.status == ProcessStatus.WAITING_EVENT: |
| #373 | parent.status = ProcessStatus.RUNNABLE |
| #374 | parent.status_message = None |
| #375 | parent.updated_at = utc_now() |
| #376 | self.store.update_process(parent) |
| #377 | self.audit.record( |
| #378 | actor=pid, |
| #379 | action="process.wait", |
| #380 | target=f"process:{child}", |
| #381 | output_refs=[result_handle.oid] if result_handle else [], |
| #382 | decision={"child_status": child_proc.status.value}, |
| #383 | ) |
| #384 | return ProcessResult(pid=child, status=child_proc.status, result=result_handle, message=child_proc.status_message) |
| #385 | |
| #386 | def set_working_directory(self, pid: str, path: str) -> AgentProcess: |
| #387 | process = self._get(pid) |
| #388 | if process.status in self.TERMINAL_STATUSES: |
| #389 | raise ProcessError(f"cannot change working directory for terminated process: {pid}") |
| #390 | process.working_directory = self._normalize_working_directory(path) |
| #391 | process.updated_at = utc_now() |
| #392 | self.store.update_process(process) |
| #393 | self.audit.record( |
| #394 | actor=pid, |
| #395 | action="process.chdir", |
| #396 | target=f"process:{pid}", |
| #397 | decision={"working_directory": process.working_directory}, |
| #398 | ) |
| #399 | return process |
| #400 | |
| #401 | def working_directory(self, pid: str) -> str: |
| #402 | return self._get(pid).working_directory |
| #403 | |
| #404 | def list_children(self, pid: str, include_terminal: bool = True) -> builtins.list[AgentProcess]: |
| #405 | self._get(pid) |
| #406 | children = [process for process in self.store.list_processes() if process.parent_pid == pid] |
| #407 | if not include_terminal: |
| #408 | children = [process for process in children if process.status not in self.TERMINAL_STATUSES] |
| #409 | children.sort(key=lambda process: process.created_at) |
| #410 | self.audit.record( |
| #411 | actor=pid, |
| #412 | action="process.list_children", |
| #413 | target=f"process:{pid}", |
| #414 | decision={"count": len(children), "include_terminal": include_terminal}, |
| #415 | ) |
| #416 | return children |
| #417 | |
| #418 | def signal_child( |
| #419 | self, |
| #420 | pid: str, |
| #421 | child: str, |
| #422 | signal: ProcessSignal | str, |
| #423 | reason: str | None = None, |
| #424 | ) -> AgentProcess: |
| #425 | child_proc = self._require_child(pid, child) |
| #426 | sig = ProcessSignal(signal) |
| #427 | self._apply_signal( |
| #428 | child_proc, |
| #429 | sig, |
| #430 | payload={"reason": reason} if reason else {}, |
| #431 | actor=pid, |
| #432 | action="process.signal_child", |
| #433 | ) |
| #434 | updated = self._get(child) |
| #435 | if updated.status in self.TERMINAL_STATUSES: |
| #436 | self._wake_parent_waiting_on_child(updated) |
| #437 | return updated |
| #438 | |
| #439 | def merge_child_memory( |
| #440 | self, |
| #441 | pid: str, |
| #442 | child: str, |
| #443 | policy: MergePolicy | None = None, |
| #444 | ) -> MergeResult: |
| #445 | child_proc = self._require_child(pid, child) |
| #446 | if child_proc.status not in self.TERMINAL_STATUSES: |
| #447 | raise ProcessError(f"cannot merge running child process: {child}") |
| #448 | if child_proc.memory_view is None: |
| #449 | return MergeResult(merged_oids=[], skipped_oids=[]) |
| #450 | result = self.memory.merge_view(pid, child_proc.memory_view, policy=policy) |
| #451 | parent = self._get(pid) |
| #452 | for oid in result.merged_oids: |
| #453 | handle = self.capabilities.handle_for_object( |
| #454 | pid, |
| #455 | oid, |
| #456 | {"read", "materialize", "link", "diff"}, |
| #457 | issued_by=f"process.merge_child_memory:{child}", |
| #458 | ) |
| #459 | self._add_handle_to_process_view(parent, handle) |
| #460 | self.audit.record( |
| #461 | actor=pid, |
| #462 | action="process.merge_child_memory", |
| #463 | target=f"process:{child}", |
| #464 | output_refs=result.merged_oids, |
| #465 | decision={"merged": len(result.merged_oids), "skipped": result.skipped_oids}, |
| #466 | ) |
| #467 | return result |
| #468 | |
| #469 | def signal(self, target: str, signal: ProcessSignal | str, payload: dict[str, Any] | None = None) -> None: |
| #470 | proc = self._get(target) |
| #471 | sig = ProcessSignal(signal) |
| #472 | self._apply_signal(proc, sig, payload=payload or {}, actor="runtime", action="process.signal") |
| #473 | updated = self._get(target) |
| #474 | if updated.status in self.TERMINAL_STATUSES: |
| #475 | self._wake_parent_waiting_on_child(updated) |
| #476 | |
| #477 | def _apply_signal( |
| #478 | self, |
| #479 | proc: AgentProcess, |
| #480 | sig: ProcessSignal, |
| #481 | payload: dict[str, Any], |
| #482 | actor: str, |
| #483 | action: str, |
| #484 | ) -> None: |
| #485 | if sig == ProcessSignal.PAUSE: |
| #486 | proc.status = ProcessStatus.PAUSED |
| #487 | elif sig == ProcessSignal.RESUME: |
| #488 | proc.status = ProcessStatus.RUNNABLE |
| #489 | elif sig in {ProcessSignal.CANCEL, ProcessSignal.TERMINATE}: |
| #490 | proc.status = ProcessStatus.KILLED |
| #491 | proc.status_message = payload.get("reason") |
| #492 | proc.updated_at = utc_now() |
| #493 | self.store.update_process(proc) |
| #494 | self.events.emit( |
| #495 | EventType.PROCESS_SIGNAL, |
| #496 | source=actor, |
| #497 | target=proc.pid, |
| #498 | payload={"signal": sig.value, "payload": payload or {}}, |
| #499 | ) |
| #500 | self.audit.record( |
| #501 | actor=actor, |
| #502 | action=action, |
| #503 | target=f"process:{proc.pid}", |
| #504 | decision={"signal": sig.value, "payload": payload or {}}, |
| #505 | ) |
| #506 | |
| #507 | def pause(self, pid: str, reason: str) -> None: |
| #508 | self.signal(pid, ProcessSignal.PAUSE, {"reason": reason}) |
| #509 | |
| #510 | def resume(self, pid: str) -> None: |
| #511 | self.signal(pid, ProcessSignal.RESUME, {}) |
| #512 | |
| #513 | def cancel(self, pid: str, reason: str) -> None: |
| #514 | self.signal(pid, ProcessSignal.CANCEL, {"reason": reason}) |
| #515 | |
| #516 | def exit(self, pid: str, result: ObjectHandle | None = None, failed: bool = False, message: str | None = None) -> None: |
| #517 | process = self._get(pid) |
| #518 | process.status = ProcessStatus.FAILED if failed else ProcessStatus.EXITED |
| #519 | process.status_message = f"result_oid:{result.oid}" if result is not None else message |
| #520 | process.updated_at = utc_now() |
| #521 | self.store.update_process(process) |
| #522 | self.events.emit( |
| #523 | EventType.PROCESS_EXITED, |
| #524 | source=pid, |
| #525 | target=process.parent_pid, |
| #526 | payload={"pid": pid, "status": process.status.value, "result_oid": result.oid if result else None}, |
| #527 | ) |
| #528 | self.audit.record( |
| #529 | actor=pid, |
| #530 | action="process.exit", |
| #531 | target=f"process:{pid}", |
| #532 | output_refs=[result.oid] if result else [], |
| #533 | decision={"status": process.status.value, "message": message}, |
| #534 | ) |
| #535 | # Reclaim volatile Object Memory owned by this process after its final |
| #536 | # state has been recorded. |
| #537 | self.memory.release_process_owned(pid, preserve_oids={result.oid} if result is not None else set()) |
| #538 | self._wake_parent_waiting_on_child(process) |
| #539 | |
| #540 | def get(self, pid: str) -> AgentProcess: |
| #541 | return self._get(pid) |
| #542 | |
| #543 | def list(self) -> builtins.list[AgentProcess]: |
| #544 | return self.store.list_processes() |
| #545 | |
| #546 | def _get(self, pid: str) -> AgentProcess: |
| #547 | process = self.store.get_process(pid) |
| #548 | if process is None: |
| #549 | raise NotFound(f"process not found: {pid}") |
| #550 | return process |
| #551 | |
| #552 | def _default_resource_budget(self) -> ResourceBudget: |
| #553 | defaults = self.config.process |
| #554 | return ResourceBudget( |
| #555 | max_tool_calls=defaults.max_tool_calls, |
| #556 | max_child_processes=defaults.max_child_processes, |
| #557 | max_runtime_seconds=defaults.max_runtime_seconds, |
| #558 | max_materialized_tokens=defaults.max_materialized_tokens, |
| #559 | ) |
| #560 | |
| #561 | def _normalize_working_directory(self, path: str | None) -> str: |
| #562 | raw = (path or self.config.process.default_working_directory).replace("\\", "/").strip() |
| #563 | if raw in {"", "."}: |
| #564 | return "." |
| #565 | if PurePosixPath(raw).is_absolute() or PureWindowsPath(raw).is_absolute(): |
| #566 | raise ProcessError(f"working directory must be workspace-relative: {path}") |
| #567 | parts: list[str] = [] |
| #568 | for part in raw.split("/"): |
| #569 | if part in {"", "."}: |
| #570 | continue |
| #571 | if part == "..": |
| #572 | if not parts: |
| #573 | raise ProcessError(f"working directory escapes workspace root: {path}") |
| #574 | parts.pop() |
| #575 | continue |
| #576 | parts.append(part) |
| #577 | return "/".join(parts) if parts else "." |
| #578 | |
| #579 | def _child_resource_budget(self, parent: AgentProcess) -> ResourceBudget: |
| #580 | return ResourceBudget( |
| #581 | max_tool_calls=max( |
| #582 | self.config.process.fork_min_tool_calls, |
| #583 | parent.resource_budget.max_tool_calls // self.config.process.fork_budget_divisor, |
| #584 | ), |
| #585 | max_child_processes=max( |
| #586 | self.config.process.fork_min_child_processes, |
| #587 | parent.resource_budget.max_child_processes // self.config.process.fork_budget_divisor, |
| #588 | ), |
| #589 | max_runtime_seconds=parent.resource_budget.max_runtime_seconds, |
| #590 | max_materialized_tokens=parent.resource_budget.max_materialized_tokens, |
| #591 | ) |
| #592 | |
| #593 | def _ensure_goal(self, pid: str, goal: dict[str, Any] | str | ObjectHandle | None) -> ObjectHandle: |
| #594 | if isinstance(goal, ObjectHandle): |
| #595 | return goal |
| #596 | default_goal = self.config.process.default_goal_text |
| #597 | payload = {"text": goal or default_goal} if isinstance(goal, str) or goal is None else goal |
| #598 | return self.memory.create_object( |
| #599 | pid=pid, |
| #600 | object_type=ObjectType.GOAL, |
| #601 | payload=payload, |
| #602 | metadata=ObjectMetadata(title="Process goal", tags=["goal"]), |
| #603 | immutable=True, |
| #604 | ) |
| #605 | |
| #606 | def _grant_specs(self, pid: str, specs: Iterable[dict[str, Any]], issued_by: str) -> None: |
| #607 | for spec in specs: |
| #608 | self.capabilities.grant( |
| #609 | subject=pid, |
| #610 | resource=spec["resource"], |
| #611 | rights=spec.get("rights", [CapabilityRight.READ.value]), |
| #612 | issued_by=issued_by, |
| #613 | constraints=spec.get("constraints"), |
| #614 | expires_at=spec.get("expires_at"), |
| #615 | delegable=spec.get("delegable", False), |
| #616 | revocable=spec.get("revocable", True), |
| #617 | ) |
| #618 | |
| #619 | def _inherit_capability_specs( |
| #620 | self, |
| #621 | parent_pid: str, |
| #622 | child_pid: str, |
| #623 | specs: Iterable[dict[str, Any]], |
| #624 | issued_by: str, |
| #625 | ) -> None: |
| #626 | for spec in specs: |
| #627 | self.capabilities.inherit( |
| #628 | parent=parent_pid, |
| #629 | child=child_pid, |
| #630 | resource=spec["resource"], |
| #631 | rights=spec.get("rights", [CapabilityRight.READ.value]), |
| #632 | issued_by=issued_by, |
| #633 | constraints=spec.get("constraints") if isinstance(spec.get("constraints"), dict) else None, |
| #634 | ) |
| #635 | |
| #636 | def _validate_inherit_capability_specs(self, parent_pid: str, specs: Iterable[dict[str, Any]]) -> None: |
| #637 | for spec in specs: |
| #638 | resource = spec["resource"] |
| #639 | rights = spec.get("rights", [CapabilityRight.READ.value]) |
| #640 | for right in rights: |
| #641 | policy = self.capabilities.permission_policy(parent_pid, resource, right) |
| #642 | if policy != CapabilityManager.ALWAYS_ALLOW: |
| #643 | raise CapabilityDenied( |
| #644 | f"{parent_pid} cannot inherit {right} on {resource}; parent policy is {policy}" |
| #645 | ) |
| #646 | |
| #647 | def _fork_mode_to_view_mode(self, mode: ForkMode) -> ViewMode: |
| #648 | if mode == ForkMode.COPY: |
| #649 | return ViewMode.COPY_ON_WRITE |
| #650 | if mode == ForkMode.SPECULATIVE: |
| #651 | return ViewMode.EPHEMERAL |
| #652 | if mode == ForkMode.WORKER: |
| #653 | return ViewMode.READ_ONLY |
| #654 | return ViewMode.READ_ONLY |
| #655 | |
| #656 | def _run_after_spawn_hooks(self, pid: str, image_id: str) -> None: |
| #657 | for hook in self._after_spawn_hooks: |
| #658 | hook(pid, image_id) |
| #659 | |
| #660 | def _require_child(self, parent: str, child: str) -> AgentProcess: |
| #661 | self._get(parent) |
| #662 | child_proc = self._get(child) |
| #663 | if child_proc.parent_pid != parent: |
| #664 | raise ProcessError(f"{child} is not a child of {parent}") |
| #665 | return child_proc |
| #666 | |
| #667 | def _require_child_budget(self, parent: AgentProcess) -> None: |
| #668 | child_count = len([process for process in self.store.list_processes() if process.parent_pid == parent.pid]) |
| #669 | if child_count >= parent.resource_budget.max_child_processes: |
| #670 | raise ProcessError( |
| #671 | f"process {parent.pid} exhausted child process budget: " |
| #672 | f"{child_count}/{parent.resource_budget.max_child_processes}" |
| #673 | ) |
| #674 | |
| #675 | def _add_handle_to_process_view(self, process: AgentProcess, handle: ObjectHandle) -> None: |
| #676 | if process.memory_view is None: |
| #677 | process.memory_view = self.memory.create_view(process.pid, [handle], mode=ViewMode.READ_ONLY) |
| #678 | elif all(existing.oid != handle.oid for existing in process.memory_view.roots): |
| #679 | process.memory_view.roots.append(handle) |
| #680 | process.updated_at = utc_now() |
| #681 | self.store.update_process(process) |
| #682 | |
| #683 | def _wake_parent_waiting_on_child(self, child: AgentProcess) -> None: |
| #684 | if child.parent_pid is None: |
| #685 | return |
| #686 | parent = self.store.get_process(child.parent_pid) |
| #687 | if parent is None: |
| #688 | return |
| #689 | if parent.status != ProcessStatus.WAITING_EVENT: |
| #690 | return |
| #691 | if parent.status_message != f"waiting for {child.pid}": |
| #692 | return |
| #693 | parent.status = ProcessStatus.RUNNABLE |
| #694 | parent.status_message = None |
| #695 | parent.updated_at = utc_now() |
| #696 | self.store.update_process(parent) |
| #697 | self.audit.record( |
| #698 | actor="process", |
| #699 | action="process.wait_wake", |
| #700 | target=f"process:{parent.pid}", |
| #701 | decision={"child": child.pid, "child_status": child.status.value}, |
| #702 | ) |
| #703 |