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 json |
| #4 | from typing import Any |
| #5 | |
| #6 | from pydantic import BaseModel, Field, field_validator |
| #7 | |
| #8 | from agent_libos.config import DEFAULT_CONFIG |
| #9 | from agent_libos.models.exceptions import NotFound |
| #10 | from agent_libos.models import ( |
| #11 | AgentProcess, |
| #12 | CapabilityRight, |
| #13 | ForkMode, |
| #14 | MemoryViewSpec, |
| #15 | MergePolicy, |
| #16 | ObjectHandle, |
| #17 | ObjectMetadata, |
| #18 | ObjectRight, |
| #19 | ObjectType, |
| #20 | ProcessSignal, |
| #21 | ViewMode, |
| #22 | ) |
| #23 | from agent_libos.tools.base import SyncAgentTool, ToolContext, ToolErrorCode, ToolExecutionError, ToolPolicy |
| #24 | |
| #25 | _TOOL_DEFAULTS = DEFAULT_CONFIG.tools |
| #26 | |
| #27 | |
| #28 | class ProcessExitArgs(BaseModel): |
| #29 | payload: dict[str, Any] | None = Field(default=None, description="Optional structured final result.") |
| #30 | result_oid: str | None = Field(default=None, description="Existing object id to use as process result.") |
| #31 | message: str | None = Field(default=None, description="Optional status message.") |
| #32 | |
| #33 | @field_validator("payload", mode="before") |
| #34 | @classmethod |
| #35 | def parse_json_payload(cls, value: Any) -> Any: |
| #36 | if isinstance(value, str): |
| #37 | try: |
| #38 | decoded = json.loads(value) |
| #39 | except json.JSONDecodeError: |
| #40 | return {"content": value} |
| #41 | if isinstance(decoded, dict): |
| #42 | return decoded |
| #43 | return {"value": decoded} |
| #44 | return value |
| #45 | |
| #46 | |
| #47 | class ProcessExitOutput(BaseModel): |
| #48 | status: str |
| #49 | result_oid: str | None = None |
| #50 | |
| #51 | |
| #52 | class GetWorkingDirectoryArgs(BaseModel): |
| #53 | pass |
| #54 | |
| #55 | |
| #56 | class GetWorkingDirectoryOutput(BaseModel): |
| #57 | working_directory: str |
| #58 | |
| #59 | |
| #60 | class SetWorkingDirectoryArgs(BaseModel): |
| #61 | path: str = Field(description="Workspace-relative directory, resolved from the current process working directory.") |
| #62 | |
| #63 | |
| #64 | class SetWorkingDirectoryOutput(BaseModel): |
| #65 | working_directory: str |
| #66 | |
| #67 | |
| #68 | class ExecProcessArgs(BaseModel): |
| #69 | image: str = Field(description="Target AgentImage id for the current process.") |
| #70 | args: dict[str, Any] = Field(default_factory=dict, description="Structured exec arguments recorded in audit.") |
| #71 | goal: str | dict[str, Any] | None = Field( |
| #72 | default=None, |
| #73 | description="Optional replacement goal for the new image.", |
| #74 | ) |
| #75 | preserve_memory: bool = Field(default=True, description="Keep the current MemoryView unless a replacement is requested.") |
| #76 | preserve_capabilities: bool = Field( |
| #77 | default=False, |
| #78 | description="Keep existing capabilities. Exec never grants capabilities required by the target image.", |
| #79 | ) |
| #80 | |
| #81 | |
| #82 | class ExecProcessOutput(BaseModel): |
| #83 | pid: str |
| #84 | old_image: str |
| #85 | new_image: str |
| #86 | status: str |
| #87 | goal_oid: str | None |
| #88 | preserve_memory: bool |
| #89 | preserve_capabilities: bool |
| #90 | active_tools: list[str] |
| #91 | |
| #92 | |
| #93 | class ForkChildProcessArgs(BaseModel): |
| #94 | goal: str | dict[str, Any] = Field(description="Goal for the child AgentProcess.") |
| #95 | mode: str = Field(default=ForkMode.WORKER.value, description="Fork mode: copy, restricted, speculative, or worker.") |
| #96 | image: str | None = Field( |
| #97 | default=None, |
| #98 | description="Optional image id. MVP tool only allows the current process image.", |
| #99 | ) |
| #100 | include_parent_roots: bool = Field( |
| #101 | default=True, |
| #102 | description="Whether the child view includes the parent's current MemoryView roots.", |
| #103 | ) |
| #104 | root_oids: list[str] | None = Field( |
| #105 | default=None, |
| #106 | description="Optional explicit Object ids to expose to the child instead of all parent roots.", |
| #107 | ) |
| #108 | inherit_read_files: list[str] = Field( |
| #109 | default_factory=list, |
| #110 | description="Workspace-relative files whose read capability should be inherited by the child.", |
| #111 | ) |
| #112 | inherit_write_files: list[str] = Field( |
| #113 | default_factory=list, |
| #114 | description="Workspace-relative files whose write capability should be inherited by the child.", |
| #115 | ) |
| #116 | inherit_read_dirs: list[str] = Field( |
| #117 | default_factory=list, |
| #118 | description="Workspace-relative directories whose read capability should be inherited by the child.", |
| #119 | ) |
| #120 | inherit_write_dirs: list[str] = Field( |
| #121 | default_factory=list, |
| #122 | description="Workspace-relative directories whose write capability should be inherited by the child.", |
| #123 | ) |
| #124 | inherit_capabilities: list[dict[str, Any]] = Field( |
| #125 | default_factory=list, |
| #126 | description="Explicit capability specs to inherit, each with resource and rights.", |
| #127 | ) |
| #128 | working_directory: str | None = Field( |
| #129 | default=None, |
| #130 | description="Optional child working directory. Defaults to the parent's current working directory.", |
| #131 | ) |
| #132 | |
| #133 | |
| #134 | class ForkChildProcessOutput(BaseModel): |
| #135 | child_pid: str |
| #136 | parent_pid: str |
| #137 | image: str |
| #138 | mode: str |
| #139 | status: str |
| #140 | goal_oid: str | None |
| #141 | inherited_capabilities: list[dict[str, Any]] |
| #142 | working_directory: str |
| #143 | |
| #144 | |
| #145 | class SpawnChildProcessArgs(BaseModel): |
| #146 | goal: str | dict[str, Any] = Field(description="Goal for the fresh child AgentProcess.") |
| #147 | image: str | None = Field(default=None, description="Optional child image id. Defaults to the parent image.") |
| #148 | inherit_read_files: list[str] = Field( |
| #149 | default_factory=list, |
| #150 | description="Workspace-relative files whose read capability should be inherited by the child.", |
| #151 | ) |
| #152 | inherit_write_files: list[str] = Field( |
| #153 | default_factory=list, |
| #154 | description="Workspace-relative files whose write capability should be inherited by the child.", |
| #155 | ) |
| #156 | inherit_read_dirs: list[str] = Field( |
| #157 | default_factory=list, |
| #158 | description="Workspace-relative directories whose read capability should be inherited by the child.", |
| #159 | ) |
| #160 | inherit_write_dirs: list[str] = Field( |
| #161 | default_factory=list, |
| #162 | description="Workspace-relative directories whose write capability should be inherited by the child.", |
| #163 | ) |
| #164 | inherit_capabilities: list[dict[str, Any]] = Field( |
| #165 | default_factory=list, |
| #166 | description="Explicit capability specs to inherit, each with resource and rights.", |
| #167 | ) |
| #168 | working_directory: str | None = Field( |
| #169 | default=None, |
| #170 | description="Optional child working directory. Defaults to the parent's current working directory.", |
| #171 | ) |
| #172 | |
| #173 | |
| #174 | class SpawnChildProcessOutput(BaseModel): |
| #175 | child_pid: str |
| #176 | parent_pid: str |
| #177 | image: str |
| #178 | status: str |
| #179 | goal_oid: str | None |
| #180 | inherited_capabilities: list[dict[str, Any]] |
| #181 | fresh_memory_view: bool |
| #182 | working_directory: str |
| #183 | |
| #184 | |
| #185 | class WaitChildProcessArgs(BaseModel): |
| #186 | child_pid: str = Field(description="Direct child process id to wait for.") |
| #187 | block: bool = Field(default=True, description="If false, return ready=false when the child is still running.") |
| #188 | |
| #189 | |
| #190 | class WaitChildProcessOutput(BaseModel): |
| #191 | child_pid: str |
| #192 | status: str |
| #193 | ready: bool |
| #194 | result_oid: str | None = None |
| #195 | message: str | None = None |
| #196 | |
| #197 | |
| #198 | class ChildProcessInfo(BaseModel): |
| #199 | pid: str |
| #200 | image: str |
| #201 | status: str |
| #202 | working_directory: str |
| #203 | goal_oid: str | None |
| #204 | result_oid: str | None = None |
| #205 | status_message: str | None = None |
| #206 | |
| #207 | |
| #208 | class ListChildProcessesArgs(BaseModel): |
| #209 | include_terminal: bool = Field(default=True, description="Whether exited/failed/killed children are included.") |
| #210 | |
| #211 | |
| #212 | class ListChildProcessesOutput(BaseModel): |
| #213 | children: list[ChildProcessInfo] |
| #214 | |
| #215 | |
| #216 | class SignalChildProcessArgs(BaseModel): |
| #217 | child_pid: str = Field(description="Direct child process id to signal.") |
| #218 | signal: str = Field(description="Signal to send: pause, resume, cancel, or terminate.") |
| #219 | reason: str | None = Field(default=None, description="Optional reason stored in the child status message.") |
| #220 | |
| #221 | |
| #222 | class SignalChildProcessOutput(BaseModel): |
| #223 | child_pid: str |
| #224 | signal: str |
| #225 | status: str |
| #226 | |
| #227 | |
| #228 | class MergeChildMemoryArgs(BaseModel): |
| #229 | child_pid: str = Field(description="Direct child process id whose memory should be merged.") |
| #230 | include_child_created: bool = Field(default=True, description="Include objects created by the child.") |
| #231 | |
| #232 | |
| #233 | class MergeChildMemoryOutput(BaseModel): |
| #234 | child_pid: str |
| #235 | merged_oids: list[str] |
| #236 | skipped_oids: list[str] |
| #237 | |
| #238 | |
| #239 | class ProcessExitTool(SyncAgentTool[ProcessExitArgs]): |
| #240 | name = "process_exit" |
| #241 | description = ( |
| #242 | "Exit the current Agent Process with an optional final result. " |
| #243 | "This is a Skills/Tools Layer wrapper over process lifecycle primitives." |
| #244 | ) |
| #245 | args_schema = ProcessExitArgs |
| #246 | output_schema = ProcessExitOutput |
| #247 | policy = ToolPolicy(side_effects=False, idempotent=True, timeout_s=_TOOL_DEFAULTS.standard_timeout_s) |
| #248 | tags = ["process", "lifecycle"] |
| #249 | |
| #250 | def run(self, args: ProcessExitArgs, ctx: ToolContext) -> ProcessExitOutput: |
| #251 | runtime = ctx.runtime |
| #252 | if runtime is None: |
| #253 | raise ToolExecutionError("Runtime is unavailable.", code=ToolErrorCode.EXECUTION_ERROR) |
| #254 | result_handle: ObjectHandle | None = None |
| #255 | if args.result_oid: |
| #256 | result_handle = runtime.capability.handle_for_object( |
| #257 | ctx.pid, |
| #258 | args.result_oid, |
| #259 | {"read", "materialize", "link", "diff"}, |
| #260 | issued_by="process_exit_tool", |
| #261 | ) |
| #262 | elif args.payload is not None: |
| #263 | result_handle = runtime.memory.create_object( |
| #264 | pid=ctx.pid, |
| #265 | object_type=ObjectType.SUMMARY, |
| #266 | payload=args.payload, |
| #267 | metadata=ObjectMetadata(title="Process final result", tags=["final"]), |
| #268 | ) |
| #269 | runtime.process.exit(ctx.pid, result=result_handle, message=args.message) |
| #270 | result_oid = result_handle.oid if result_handle is not None else None |
| #271 | return ProcessExitOutput(status="exited", result_oid=result_oid) |
| #272 | |
| #273 | |
| #274 | class GetWorkingDirectoryTool(SyncAgentTool[GetWorkingDirectoryArgs]): |
| #275 | name = "get_working_directory" |
| #276 | description = "Return this AgentProcess working directory, relative to the runtime workspace root." |
| #277 | args_schema = GetWorkingDirectoryArgs |
| #278 | output_schema = GetWorkingDirectoryOutput |
| #279 | policy = ToolPolicy(side_effects=False, idempotent=True, timeout_s=_TOOL_DEFAULTS.standard_timeout_s) |
| #280 | tags = ["process", "working_directory"] |
| #281 | |
| #282 | def run(self, args: GetWorkingDirectoryArgs, ctx: ToolContext) -> GetWorkingDirectoryOutput: |
| #283 | runtime = ctx.runtime |
| #284 | if runtime is None: |
| #285 | raise ToolExecutionError("Runtime is unavailable.", code=ToolErrorCode.EXECUTION_ERROR) |
| #286 | return GetWorkingDirectoryOutput(working_directory=runtime.process.working_directory(ctx.pid)) |
| #287 | |
| #288 | |
| #289 | class SetWorkingDirectoryTool(SyncAgentTool[SetWorkingDirectoryArgs]): |
| #290 | name = "set_working_directory" |
| #291 | description = ( |
| #292 | "Set this AgentProcess working directory. Relative filesystem and shell tool paths resolve from it." |
| #293 | ) |
| #294 | args_schema = SetWorkingDirectoryArgs |
| #295 | output_schema = SetWorkingDirectoryOutput |
| #296 | policy = ToolPolicy(side_effects=True, idempotent=False, timeout_s=_TOOL_DEFAULTS.standard_timeout_s) |
| #297 | tags = ["process", "working_directory"] |
| #298 | |
| #299 | def run(self, args: SetWorkingDirectoryArgs, ctx: ToolContext) -> SetWorkingDirectoryOutput: |
| #300 | runtime = ctx.runtime |
| #301 | if runtime is None: |
| #302 | raise ToolExecutionError("Runtime is unavailable.", code=ToolErrorCode.EXECUTION_ERROR) |
| #303 | process = runtime.set_process_working_directory(ctx.pid, args.path) |
| #304 | return SetWorkingDirectoryOutput(working_directory=process.working_directory) |
| #305 | |
| #306 | |
| #307 | class ExecProcessTool(SyncAgentTool[ExecProcessArgs]): |
| #308 | name = "exec_process" |
| #309 | description = ( |
| #310 | "Replace the current AgentProcess image and tool table without changing pid. " |
| #311 | "Exec is not a permission escalation path: target image required capabilities are not granted automatically." |
| #312 | ) |
| #313 | args_schema = ExecProcessArgs |
| #314 | output_schema = ExecProcessOutput |
| #315 | policy = ToolPolicy(side_effects=True, idempotent=False, timeout_s=_TOOL_DEFAULTS.standard_timeout_s) |
| #316 | tags = ["process", "lifecycle", "exec"] |
| #317 | |
| #318 | def run(self, args: ExecProcessArgs, ctx: ToolContext) -> ExecProcessOutput: |
| #319 | runtime = ctx.runtime |
| #320 | if runtime is None: |
| #321 | raise ToolExecutionError("Runtime is unavailable.", code=ToolErrorCode.EXECUTION_ERROR) |
| #322 | old_image = runtime.process.get(ctx.pid).image_id |
| #323 | try: |
| #324 | process = runtime.exec_process( |
| #325 | ctx.pid, |
| #326 | args.image, |
| #327 | args=args.args, |
| #328 | goal=args.goal, |
| #329 | preserve_memory=args.preserve_memory, |
| #330 | preserve_capabilities=args.preserve_capabilities, |
| #331 | ) |
| #332 | except NotFound as exc: |
| #333 | raise ToolExecutionError( |
| #334 | "Target image does not exist.", |
| #335 | code=ToolErrorCode.VALIDATION_ERROR, |
| #336 | details={"image": args.image}, |
| #337 | ) from exc |
| #338 | return ExecProcessOutput( |
| #339 | pid=process.pid, |
| #340 | old_image=old_image, |
| #341 | new_image=process.image_id, |
| #342 | status=process.status.value, |
| #343 | goal_oid=process.goal_oid, |
| #344 | preserve_memory=args.preserve_memory, |
| #345 | preserve_capabilities=args.preserve_capabilities, |
| #346 | active_tools=sorted(process.tool_table), |
| #347 | ) |
| #348 | |
| #349 | |
| #350 | class ForkChildProcessTool(SyncAgentTool[ForkChildProcessArgs]): |
| #351 | name = "fork_child_process" |
| #352 | description = ( |
| #353 | "Fork a direct child AgentProcess with an attenuated MemoryView. " |
| #354 | "This creates an Agent libOS child process, not a host OS process." |
| #355 | ) |
| #356 | args_schema = ForkChildProcessArgs |
| #357 | output_schema = ForkChildProcessOutput |
| #358 | policy = ToolPolicy(side_effects=True, idempotent=False, timeout_s=_TOOL_DEFAULTS.standard_timeout_s) |
| #359 | tags = ["process", "child", "fork"] |
| #360 | |
| #361 | def run(self, args: ForkChildProcessArgs, ctx: ToolContext) -> ForkChildProcessOutput: |
| #362 | runtime = ctx.runtime |
| #363 | if runtime is None: |
| #364 | raise ToolExecutionError("Runtime is unavailable.", code=ToolErrorCode.EXECUTION_ERROR) |
| #365 | parent = runtime.process.get(ctx.pid) |
| #366 | image = args.image or parent.image_id |
| #367 | if image != parent.image_id: |
| #368 | raise ToolExecutionError( |
| #369 | "Forking into a different image is not exposed to processes yet.", |
| #370 | code=ToolErrorCode.PERMISSION_DENIED, |
| #371 | details={"requested_image": image, "parent_image": parent.image_id}, |
| #372 | ) |
| #373 | try: |
| #374 | fork_mode = ForkMode(args.mode) |
| #375 | except ValueError as exc: |
| #376 | raise ToolExecutionError( |
| #377 | "Invalid fork mode.", |
| #378 | code=ToolErrorCode.VALIDATION_ERROR, |
| #379 | details={"mode": args.mode, "allowed": [mode.value for mode in ForkMode]}, |
| #380 | ) from exc |
| #381 | roots = self._selected_roots(runtime, ctx.pid, args.root_oids) |
| #382 | view_spec = MemoryViewSpec( |
| #383 | roots=roots, |
| #384 | mode=_view_mode_for_fork(fork_mode), |
| #385 | include_parent_roots=args.include_parent_roots, |
| #386 | ) |
| #387 | inherit_specs = self._inheritance_specs(runtime, args, cwd=parent.working_directory) |
| #388 | child_cwd = ( |
| #389 | runtime.resolve_process_working_directory(ctx.pid, args.working_directory) |
| #390 | if args.working_directory is not None |
| #391 | else parent.working_directory |
| #392 | ) |
| #393 | child_pid = runtime.process.fork( |
| #394 | parent=ctx.pid, |
| #395 | goal=args.goal, |
| #396 | memory_view=view_spec, |
| #397 | inherit_capabilities=inherit_specs, |
| #398 | image=image, |
| #399 | mode=fork_mode, |
| #400 | working_directory=child_cwd, |
| #401 | ) |
| #402 | child = runtime.process.get(child_pid) |
| #403 | return ForkChildProcessOutput( |
| #404 | child_pid=child.pid, |
| #405 | parent_pid=ctx.pid, |
| #406 | image=child.image_id, |
| #407 | mode=fork_mode.value, |
| #408 | status=child.status.value, |
| #409 | goal_oid=child.goal_oid, |
| #410 | inherited_capabilities=inherit_specs, |
| #411 | working_directory=child.working_directory, |
| #412 | ) |
| #413 | |
| #414 | def _selected_roots(self, runtime: Any, pid: str, root_oids: list[str] | None) -> list[ObjectHandle] | None: |
| #415 | if root_oids is None: |
| #416 | return None |
| #417 | process = runtime.process.get(pid) |
| #418 | visible = {handle.oid: handle for handle in (process.memory_view.roots if process.memory_view else [])} |
| #419 | roots: list[ObjectHandle] = [] |
| #420 | for oid in root_oids: |
| #421 | if oid in visible: |
| #422 | roots.append(visible[oid]) |
| #423 | continue |
| #424 | runtime.capability.require(pid, f"object:{oid}", ObjectRight.READ) |
| #425 | roots.append( |
| #426 | runtime.capability.handle_for_object( |
| #427 | pid, |
| #428 | oid, |
| #429 | {"read", "materialize", "diff"}, |
| #430 | issued_by="fork_child_process_tool", |
| #431 | ) |
| #432 | ) |
| #433 | return roots |
| #434 | |
| #435 | def _inheritance_specs( |
| #436 | self, |
| #437 | runtime: Any, |
| #438 | args: ForkChildProcessArgs, |
| #439 | *, |
| #440 | cwd: str, |
| #441 | ) -> list[dict[str, Any]]: |
| #442 | specs = [_normalize_capability_spec(spec) for spec in args.inherit_capabilities] |
| #443 | for path in args.inherit_read_files: |
| #444 | specs.append({"resource": runtime.filesystem.resource_for_path(path, cwd=cwd), "rights": [CapabilityRight.READ.value]}) |
| #445 | for path in args.inherit_write_files: |
| #446 | specs.append({"resource": runtime.filesystem.resource_for_path(path, cwd=cwd), "rights": [CapabilityRight.WRITE.value]}) |
| #447 | for path in args.inherit_read_dirs: |
| #448 | specs.append( |
| #449 | {"resource": runtime.filesystem.directory_resource_for_path(path, cwd=cwd), "rights": [CapabilityRight.READ.value]} |
| #450 | ) |
| #451 | for path in args.inherit_write_dirs: |
| #452 | specs.append( |
| #453 | {"resource": runtime.filesystem.directory_resource_for_path(path, cwd=cwd), "rights": [CapabilityRight.WRITE.value]} |
| #454 | ) |
| #455 | return _coalesce_capability_specs(specs) |
| #456 | |
| #457 | |
| #458 | class SpawnChildProcessTool(SyncAgentTool[SpawnChildProcessArgs]): |
| #459 | name = "spawn_child_process" |
| #460 | description = ( |
| #461 | "Create a fresh direct child AgentProcess with a new process namespace and goal-only MemoryView. " |
| #462 | "Unlike fork_child_process, this does not copy parent memory roots." |
| #463 | ) |
| #464 | args_schema = SpawnChildProcessArgs |
| #465 | output_schema = SpawnChildProcessOutput |
| #466 | policy = ToolPolicy(side_effects=True, idempotent=False, timeout_s=_TOOL_DEFAULTS.standard_timeout_s) |
| #467 | tags = ["process", "child", "spawn"] |
| #468 | |
| #469 | def run(self, args: SpawnChildProcessArgs, ctx: ToolContext) -> SpawnChildProcessOutput: |
| #470 | runtime = ctx.runtime |
| #471 | if runtime is None: |
| #472 | raise ToolExecutionError("Runtime is unavailable.", code=ToolErrorCode.EXECUTION_ERROR) |
| #473 | inherit_specs = self._inheritance_specs(runtime, args, cwd=runtime.process.working_directory(ctx.pid)) |
| #474 | child_pid = runtime.spawn_child_process( |
| #475 | parent=ctx.pid, |
| #476 | goal=args.goal, |
| #477 | image=args.image, |
| #478 | inherit_capabilities=inherit_specs, |
| #479 | working_directory=args.working_directory, |
| #480 | ) |
| #481 | child = runtime.process.get(child_pid) |
| #482 | return SpawnChildProcessOutput( |
| #483 | child_pid=child.pid, |
| #484 | parent_pid=ctx.pid, |
| #485 | image=child.image_id, |
| #486 | status=child.status.value, |
| #487 | goal_oid=child.goal_oid, |
| #488 | inherited_capabilities=inherit_specs, |
| #489 | fresh_memory_view=True, |
| #490 | working_directory=child.working_directory, |
| #491 | ) |
| #492 | |
| #493 | def _inheritance_specs( |
| #494 | self, |
| #495 | runtime: Any, |
| #496 | args: SpawnChildProcessArgs, |
| #497 | *, |
| #498 | cwd: str, |
| #499 | ) -> list[dict[str, Any]]: |
| #500 | specs = [_normalize_capability_spec(spec) for spec in args.inherit_capabilities] |
| #501 | for path in args.inherit_read_files: |
| #502 | specs.append({"resource": runtime.filesystem.resource_for_path(path, cwd=cwd), "rights": [CapabilityRight.READ.value]}) |
| #503 | for path in args.inherit_write_files: |
| #504 | specs.append({"resource": runtime.filesystem.resource_for_path(path, cwd=cwd), "rights": [CapabilityRight.WRITE.value]}) |
| #505 | for path in args.inherit_read_dirs: |
| #506 | specs.append( |
| #507 | {"resource": runtime.filesystem.directory_resource_for_path(path, cwd=cwd), "rights": [CapabilityRight.READ.value]} |
| #508 | ) |
| #509 | for path in args.inherit_write_dirs: |
| #510 | specs.append( |
| #511 | {"resource": runtime.filesystem.directory_resource_for_path(path, cwd=cwd), "rights": [CapabilityRight.WRITE.value]} |
| #512 | ) |
| #513 | return _coalesce_capability_specs(specs) |
| #514 | |
| #515 | |
| #516 | class WaitChildProcessTool(SyncAgentTool[WaitChildProcessArgs]): |
| #517 | name = "wait_child_process" |
| #518 | description = ( |
| #519 | "Wait for a direct child AgentProcess to exit, fail, or be killed. " |
| #520 | "If the child is still running and block=true, the current process is suspended and the same wait resumes later." |
| #521 | ) |
| #522 | args_schema = WaitChildProcessArgs |
| #523 | output_schema = WaitChildProcessOutput |
| #524 | policy = ToolPolicy(side_effects=True, idempotent=False, timeout_s=_TOOL_DEFAULTS.standard_timeout_s) |
| #525 | tags = ["process", "child", "wait"] |
| #526 | |
| #527 | def run(self, args: WaitChildProcessArgs, ctx: ToolContext) -> WaitChildProcessOutput: |
| #528 | runtime = ctx.runtime |
| #529 | if runtime is None: |
| #530 | raise ToolExecutionError("Runtime is unavailable.", code=ToolErrorCode.EXECUTION_ERROR) |
| #531 | try: |
| #532 | result = runtime.process.wait( |
| #533 | ctx.pid, |
| #534 | args.child_pid, |
| #535 | timeout=None if args.block else 0, |
| #536 | ) |
| #537 | except TimeoutError: |
| #538 | child = runtime.process.get(args.child_pid) |
| #539 | return WaitChildProcessOutput( |
| #540 | child_pid=args.child_pid, |
| #541 | status=child.status.value, |
| #542 | ready=False, |
| #543 | message=child.status_message, |
| #544 | ) |
| #545 | return WaitChildProcessOutput( |
| #546 | child_pid=result.pid, |
| #547 | status=result.status.value, |
| #548 | ready=True, |
| #549 | result_oid=result.result.oid if result.result is not None else None, |
| #550 | message=result.message, |
| #551 | ) |
| #552 | |
| #553 | |
| #554 | class ListChildProcessesTool(SyncAgentTool[ListChildProcessesArgs]): |
| #555 | name = "list_child_processes" |
| #556 | description = "List direct child AgentProcesses owned by the current process." |
| #557 | args_schema = ListChildProcessesArgs |
| #558 | output_schema = ListChildProcessesOutput |
| #559 | policy = ToolPolicy(side_effects=False, idempotent=True, timeout_s=_TOOL_DEFAULTS.standard_timeout_s) |
| #560 | tags = ["process", "child", "inspect"] |
| #561 | |
| #562 | def run(self, args: ListChildProcessesArgs, ctx: ToolContext) -> ListChildProcessesOutput: |
| #563 | runtime = ctx.runtime |
| #564 | if runtime is None: |
| #565 | raise ToolExecutionError("Runtime is unavailable.", code=ToolErrorCode.EXECUTION_ERROR) |
| #566 | return ListChildProcessesOutput( |
| #567 | children=[_child_info(child) for child in runtime.process.list_children(ctx.pid, args.include_terminal)] |
| #568 | ) |
| #569 | |
| #570 | |
| #571 | class SignalChildProcessTool(SyncAgentTool[SignalChildProcessArgs]): |
| #572 | name = "signal_child_process" |
| #573 | description = "Pause, resume, cancel, or terminate a direct child AgentProcess." |
| #574 | args_schema = SignalChildProcessArgs |
| #575 | output_schema = SignalChildProcessOutput |
| #576 | policy = ToolPolicy(side_effects=True, idempotent=False, timeout_s=_TOOL_DEFAULTS.standard_timeout_s) |
| #577 | tags = ["process", "child", "signal"] |
| #578 | |
| #579 | def run(self, args: SignalChildProcessArgs, ctx: ToolContext) -> SignalChildProcessOutput: |
| #580 | runtime = ctx.runtime |
| #581 | if runtime is None: |
| #582 | raise ToolExecutionError("Runtime is unavailable.", code=ToolErrorCode.EXECUTION_ERROR) |
| #583 | try: |
| #584 | signal = ProcessSignal(args.signal) |
| #585 | except ValueError as exc: |
| #586 | raise ToolExecutionError( |
| #587 | "Invalid process signal.", |
| #588 | code=ToolErrorCode.VALIDATION_ERROR, |
| #589 | details={"signal": args.signal, "allowed": ["pause", "resume", "cancel", "terminate"]}, |
| #590 | ) from exc |
| #591 | if signal not in {ProcessSignal.PAUSE, ProcessSignal.RESUME, ProcessSignal.CANCEL, ProcessSignal.TERMINATE}: |
| #592 | raise ToolExecutionError( |
| #593 | "Signal is not exposed through this tool.", |
| #594 | code=ToolErrorCode.PERMISSION_DENIED, |
| #595 | details={"signal": signal.value}, |
| #596 | ) |
| #597 | child = runtime.process.signal_child(ctx.pid, args.child_pid, signal, reason=args.reason) |
| #598 | return SignalChildProcessOutput(child_pid=child.pid, signal=signal.value, status=child.status.value) |
| #599 | |
| #600 | |
| #601 | class MergeChildMemoryTool(SyncAgentTool[MergeChildMemoryArgs]): |
| #602 | name = "merge_child_memory" |
| #603 | description = "Merge result-visible Object Memory from an exited direct child into the parent process view." |
| #604 | args_schema = MergeChildMemoryArgs |
| #605 | output_schema = MergeChildMemoryOutput |
| #606 | policy = ToolPolicy(side_effects=True, idempotent=False, timeout_s=_TOOL_DEFAULTS.standard_timeout_s) |
| #607 | tags = ["process", "child", "memory"] |
| #608 | |
| #609 | def run(self, args: MergeChildMemoryArgs, ctx: ToolContext) -> MergeChildMemoryOutput: |
| #610 | runtime = ctx.runtime |
| #611 | if runtime is None: |
| #612 | raise ToolExecutionError("Runtime is unavailable.", code=ToolErrorCode.EXECUTION_ERROR) |
| #613 | result = runtime.process.merge_child_memory( |
| #614 | ctx.pid, |
| #615 | args.child_pid, |
| #616 | policy=MergePolicy(include_child_created=args.include_child_created), |
| #617 | ) |
| #618 | return MergeChildMemoryOutput( |
| #619 | child_pid=args.child_pid, |
| #620 | merged_oids=result.merged_oids, |
| #621 | skipped_oids=result.skipped_oids, |
| #622 | ) |
| #623 | |
| #624 | |
| #625 | def _view_mode_for_fork(mode: ForkMode) -> ViewMode: |
| #626 | if mode == ForkMode.COPY: |
| #627 | return ViewMode.COPY_ON_WRITE |
| #628 | if mode == ForkMode.SPECULATIVE: |
| #629 | return ViewMode.EPHEMERAL |
| #630 | return ViewMode.READ_ONLY |
| #631 | |
| #632 | |
| #633 | def _child_info(child: AgentProcess) -> ChildProcessInfo: |
| #634 | result_oid = None |
| #635 | if child.status_message and child.status_message.startswith("result_oid:"): |
| #636 | result_oid = child.status_message.split(":", 1)[1] |
| #637 | return ChildProcessInfo( |
| #638 | pid=child.pid, |
| #639 | image=child.image_id, |
| #640 | status=child.status.value, |
| #641 | working_directory=child.working_directory, |
| #642 | goal_oid=child.goal_oid, |
| #643 | result_oid=result_oid, |
| #644 | status_message=child.status_message, |
| #645 | ) |
| #646 | |
| #647 | |
| #648 | def _normalize_capability_spec(spec: dict[str, Any]) -> dict[str, Any]: |
| #649 | resource = spec.get("resource") |
| #650 | if not isinstance(resource, str) or not resource: |
| #651 | raise ToolExecutionError( |
| #652 | "Inherited capability spec requires a non-empty resource.", |
| #653 | code=ToolErrorCode.VALIDATION_ERROR, |
| #654 | details={"spec": spec}, |
| #655 | ) |
| #656 | rights = spec.get("rights", [CapabilityRight.READ.value]) |
| #657 | if not isinstance(rights, list) or not rights: |
| #658 | raise ToolExecutionError( |
| #659 | "Inherited capability spec requires a non-empty rights list.", |
| #660 | code=ToolErrorCode.VALIDATION_ERROR, |
| #661 | details={"spec": spec}, |
| #662 | ) |
| #663 | normalized: dict[str, Any] = {"resource": resource, "rights": [str(right) for right in rights]} |
| #664 | constraints = spec.get("constraints") |
| #665 | if isinstance(constraints, dict): |
| #666 | normalized["constraints"] = constraints |
| #667 | return normalized |
| #668 | |
| #669 | |
| #670 | def _coalesce_capability_specs(specs: list[dict[str, Any]]) -> list[dict[str, Any]]: |
| #671 | by_resource: dict[str, dict[str, Any]] = {} |
| #672 | for spec in specs: |
| #673 | resource = str(spec["resource"]) |
| #674 | current = by_resource.setdefault(resource, {"resource": resource, "rights": []}) |
| #675 | current["rights"] = sorted(set(current["rights"]) | {str(right) for right in spec.get("rights", [])}) |
| #676 | if isinstance(spec.get("constraints"), dict): |
| #677 | current["constraints"] = dict(spec["constraints"]) |
| #678 | return list(by_resource.values()) |
| #679 |