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 asyncio |
| #4 | import builtins |
| #5 | import hashlib |
| #6 | from pathlib import Path |
| #7 | from typing import Any |
| #8 | |
| #9 | from agent_libos.capability.manager import CapabilityManager |
| #10 | from agent_libos.config import DEFAULT_CONFIG, AgentLibOSConfig |
| #11 | from agent_libos.models.exceptions import HumanApprovalRequired, NotFound, ProcessMessageWaitRequired, ProcessWaitRequired, ValidationError |
| #12 | from agent_libos.human.manager import HumanObjectManager |
| #13 | from agent_libos.utils.ids import new_id, utc_now |
| #14 | from agent_libos.memory.object_memory import ObjectMemoryManager |
| #15 | from agent_libos.models import ( |
| #16 | EventType, |
| #17 | ObjectMetadata, |
| #18 | ObjectType, |
| #19 | ToolCallResult, |
| #20 | ToolCandidate, |
| #21 | ToolCandidateStatus, |
| #22 | ToolHandle, |
| #23 | ToolSpec, |
| #24 | ValidationResult, |
| #25 | ) |
| #26 | from agent_libos.runtime.audit_manager import AuditManager |
| #27 | from agent_libos.runtime.event_bus import EventBus |
| #28 | from agent_libos.runtime.syscalls import LibOSSyscallSession |
| #29 | from agent_libos.storage import SQLiteStore |
| #30 | from agent_libos.tools.base import BaseAgentTool, ToolContext |
| #31 | from agent_libos.tools.sandbox import DenoTypescriptSandbox, SandboxBackend |
| #32 | |
| #33 | _TOOL_DEFAULTS = DEFAULT_CONFIG.tools |
| #34 | |
| #35 | |
| #36 | class ToolBroker: |
| #37 | """Registry and dispatch boundary for model-facing tools.""" |
| #38 | |
| #39 | def __init__( |
| #40 | self, |
| #41 | store: SQLiteStore, |
| #42 | memory: ObjectMemoryManager, |
| #43 | capabilities: CapabilityManager, |
| #44 | human: HumanObjectManager, |
| #45 | audit: AuditManager, |
| #46 | events: EventBus, |
| #47 | sandbox: SandboxBackend | None = None, |
| #48 | workspace_root: str | Path | None = None, |
| #49 | config: AgentLibOSConfig | None = None, |
| #50 | ): |
| #51 | self.config = config or DEFAULT_CONFIG |
| #52 | self.store = store |
| #53 | self.memory = memory |
| #54 | self.capabilities = capabilities |
| #55 | self.human = human |
| #56 | self.audit = audit |
| #57 | self.events = events |
| #58 | self.sandbox = sandbox or DenoTypescriptSandbox( |
| #59 | deno_executable=self.config.tools.deno_executable, |
| #60 | default_timeout_s=self.config.tools.deno_timeout_s, |
| #61 | max_rpc_calls=self.config.tools.deno_max_rpc_calls, |
| #62 | max_stdout_bytes=self.config.tools.deno_max_stdout_bytes, |
| #63 | max_stderr_bytes=self.config.tools.deno_max_stderr_bytes, |
| #64 | jsr_allowlist=self.config.tools.deno_jsr_allowlist, |
| #65 | ) |
| #66 | self.workspace_root = Path(workspace_root or Path.cwd()).resolve() |
| #67 | self._tools: dict[str, BaseAgentTool] = {} |
| #68 | self._tool_ids_by_name: dict[str, str] = {} |
| #69 | self._handles: dict[str, ToolHandle] = {} |
| #70 | self._jit_sources: dict[str, str] = {} |
| #71 | |
| #72 | def register_tool( |
| #73 | self, |
| #74 | tool: BaseAgentTool, |
| #75 | registered_by: str = "runtime", |
| #76 | scope: str = "static", |
| #77 | ephemeral: bool = False, |
| #78 | ) -> ToolHandle: |
| #79 | spec = tool.spec() |
| #80 | if spec.name in self._tool_ids_by_name: |
| #81 | raise ValueError(f"tool already registered: {spec.name}") |
| #82 | tool_id = new_id("tool") if ephemeral else _stable_static_tool_id( |
| #83 | spec.name, |
| #84 | digest_chars=self.config.tools.static_tool_id_digest_chars, |
| #85 | ) |
| #86 | handle = ToolHandle(tool_id=tool_id, name=spec.name, capability_id=None, scope=scope) |
| #87 | self._tools[tool_id] = tool |
| #88 | self._tool_ids_by_name[spec.name] = tool_id |
| #89 | self._handles[tool_id] = handle |
| #90 | existing = next((row for row in self.store.list_tools() if row["tool_id"] == tool_id), None) |
| #91 | if existing is not None and existing["name"] != spec.name: |
| #92 | raise ValueError(f"tool id collision: {tool_id}") |
| #93 | if existing is None: |
| #94 | self.store.insert_tool(handle, spec, registered_by=registered_by, created_at=utc_now(), ephemeral=ephemeral) |
| #95 | self.audit.record( |
| #96 | actor=registered_by, |
| #97 | action="tool.register", |
| #98 | target=f"tool:{tool_id}", |
| #99 | decision={ |
| #100 | "name": spec.name, |
| #101 | "version": spec.version, |
| #102 | "policy": spec.policy, |
| #103 | "tags": spec.tags, |
| #104 | }, |
| #105 | ) |
| #106 | return handle |
| #107 | |
| #108 | def configure_process_tools( |
| #109 | self, |
| #110 | pid: str, |
| #111 | tools: builtins.list[ToolHandle | str], |
| #112 | assigned_by: str = "tool_broker", |
| #113 | ) -> dict[str, str]: |
| #114 | process = self.store.get_process(pid) |
| #115 | if process is None: |
| #116 | raise NotFound(f"process not found: {pid}") |
| #117 | table: dict[str, str] = {} |
| #118 | for tool in tools: |
| #119 | handle = self.resolve(tool) |
| #120 | table[handle.name] = handle.tool_id |
| #121 | process.tool_table = table |
| #122 | process.updated_at = utc_now() |
| #123 | self.store.update_process(process) |
| #124 | self.audit.record( |
| #125 | actor=assigned_by, |
| #126 | action="process.tools.configure", |
| #127 | target=f"process:{pid}", |
| #128 | decision={"tools": sorted(table)}, |
| #129 | ) |
| #130 | return table |
| #131 | |
| #132 | def grant_execute(self, pid: str, tool: ToolHandle | str, issued_by: str = "tool_broker") -> str: |
| #133 | handle = self.resolve(tool, pid=pid) |
| #134 | if not self._process_has_tool(pid, handle): |
| #135 | raise ValidationError( |
| #136 | "tool execute capabilities are not a resource authority; configure process tools at creation time" |
| #137 | ) |
| #138 | self.audit.record( |
| #139 | actor=issued_by, |
| #140 | action="tool.execute_grant_ignored", |
| #141 | target=f"tool:{handle.tool_id}", |
| #142 | decision={ |
| #143 | "tool": handle.name, |
| #144 | "reason": "tool calls are allowed by process tool table, not execute capability", |
| #145 | }, |
| #146 | ) |
| #147 | return handle.tool_id |
| #148 | |
| #149 | def propose( |
| #150 | self, |
| #151 | pid: str, |
| #152 | spec: ToolSpec | dict[str, Any], |
| #153 | source_code: str, |
| #154 | tests: builtins.list[dict[str, Any]] | None = None, |
| #155 | requested_capabilities: builtins.list[dict[str, Any]] | None = None, |
| #156 | ) -> str: |
| #157 | tool_spec = spec if isinstance(spec, ToolSpec) else ToolSpec(**spec) |
| #158 | now = utc_now() |
| #159 | candidate = ToolCandidate( |
| #160 | candidate_id=new_id("tcand"), |
| #161 | pid=pid, |
| #162 | spec=tool_spec, |
| #163 | source_code=source_code, |
| #164 | tests=tests or [], |
| #165 | requested_capabilities=requested_capabilities or [], |
| #166 | status=ToolCandidateStatus.PROPOSED, |
| #167 | validation=None, |
| #168 | created_at=now, |
| #169 | updated_at=now, |
| #170 | ) |
| #171 | self.store.insert_tool_candidate(candidate) |
| #172 | candidate_obj = self.memory.create_object( |
| #173 | pid=pid, |
| #174 | object_type=ObjectType.TOOL_CANDIDATE, |
| #175 | payload={ |
| #176 | "candidate_id": candidate.candidate_id, |
| #177 | "language": self.sandbox.language, |
| #178 | "spec": { |
| #179 | "name": tool_spec.name, |
| #180 | "description": tool_spec.description, |
| #181 | "input_schema": tool_spec.input_schema, |
| #182 | "output_schema": tool_spec.output_schema, |
| #183 | "side_effects": tool_spec.side_effects, |
| #184 | }, |
| #185 | "tests": candidate.tests, |
| #186 | "requested_capabilities": candidate.requested_capabilities, |
| #187 | }, |
| #188 | metadata=ObjectMetadata(title=f"Tool candidate: {tool_spec.name}", tags=["tool", "candidate"]), |
| #189 | immutable=True, |
| #190 | ) |
| #191 | self.audit.record( |
| #192 | actor=pid, |
| #193 | action="tool.propose", |
| #194 | target=f"tool_candidate:{candidate.candidate_id}", |
| #195 | output_refs=[candidate_obj.oid], |
| #196 | decision={"name": tool_spec.name}, |
| #197 | ) |
| #198 | return candidate.candidate_id |
| #199 | |
| #200 | def validate(self, candidate_id: str, *, pid: str | None = None) -> ValidationResult: |
| #201 | candidate = self._get_candidate(candidate_id) |
| #202 | if pid is not None: |
| #203 | self._require_candidate_owner(candidate, pid) |
| #204 | result = self.sandbox.run_tests(candidate.source_code, candidate.tests) |
| #205 | errors = list(result.errors) |
| #206 | warnings = list(result.warnings) |
| #207 | if candidate.requested_capabilities: |
| #208 | errors.append("Deno/TypeScript JIT tools cannot request external capabilities") |
| #209 | validation = ValidationResult(ok=not errors and result.ok, errors=errors, warnings=warnings, logs=result.logs) |
| #210 | metadata = self.sandbox.metadata_for_source(candidate.source_code) |
| #211 | candidate.validation = { |
| #212 | "ok": validation.ok, |
| #213 | "errors": validation.errors, |
| #214 | "warnings": validation.warnings, |
| #215 | "logs": validation.logs, |
| #216 | **metadata, |
| #217 | } |
| #218 | candidate.status = ToolCandidateStatus.VALIDATED if validation.ok else ToolCandidateStatus.REJECTED |
| #219 | candidate.updated_at = utc_now() |
| #220 | self.store.update_tool_candidate(candidate) |
| #221 | self.audit.record( |
| #222 | actor="tool_broker", |
| #223 | action="tool.validate", |
| #224 | target=f"tool_candidate:{candidate_id}", |
| #225 | decision=candidate.validation, |
| #226 | ) |
| #227 | return validation |
| #228 | |
| #229 | def register( |
| #230 | self, |
| #231 | pid: str, |
| #232 | candidate_id: str, |
| #233 | approver: str = "policy:local", |
| #234 | scope: str = "ephemeral_process", |
| #235 | ) -> ToolHandle: |
| #236 | candidate = self._get_candidate(candidate_id) |
| #237 | self._require_candidate_owner(candidate, pid) |
| #238 | if candidate.status == ToolCandidateStatus.REGISTERED: |
| #239 | raise ValidationError(f"tool candidate is already registered: {candidate_id}") |
| #240 | process = self.store.get_process(pid) |
| #241 | if process is None: |
| #242 | raise NotFound(f"process not found: {pid}") |
| #243 | if candidate.spec.name in process.tool_table: |
| #244 | raise ValidationError(f"process already has a tool named: {candidate.spec.name}") |
| #245 | if self._name_collides_with_static_tool(candidate.spec.name): |
| #246 | raise ValidationError(f"tool name already exists: {candidate.spec.name}") |
| #247 | if candidate.status != ToolCandidateStatus.VALIDATED: |
| #248 | validation = self.validate(candidate_id, pid=pid) |
| #249 | if not validation.ok: |
| #250 | raise ValidationError("; ".join(validation.errors)) |
| #251 | candidate = self._get_candidate(candidate_id) |
| #252 | tool_id = new_id("tool") |
| #253 | handle = ToolHandle(tool_id=tool_id, name=candidate.spec.name, capability_id=None, scope=scope) |
| #254 | self._jit_sources[tool_id] = candidate.source_code |
| #255 | self._handles[tool_id] = handle |
| #256 | # JIT tool names are process-local through AgentProcess.tool_table. |
| #257 | # Keep the global name index stable for static tools and for legacy |
| #258 | # resolve(name) calls; resolve(name, pid=...) is the authority for |
| #259 | # process-scoped JIT tools and handles duplicate local names. |
| #260 | self._tool_ids_by_name.setdefault(candidate.spec.name, tool_id) |
| #261 | self.store.insert_tool(handle, candidate.spec, registered_by=approver, created_at=utc_now(), ephemeral=True) |
| #262 | candidate.status = ToolCandidateStatus.REGISTERED |
| #263 | candidate.updated_at = utc_now() |
| #264 | self.store.update_tool_candidate(candidate) |
| #265 | process.tool_table[candidate.spec.name] = tool_id |
| #266 | process.updated_at = utc_now() |
| #267 | self.store.update_process(process) |
| #268 | self.audit.record( |
| #269 | actor=approver, |
| #270 | action="tool.register", |
| #271 | target=f"tool:{tool_id}", |
| #272 | decision={"candidate_id": candidate_id, "scope": scope}, |
| #273 | ) |
| #274 | return handle |
| #275 | |
| #276 | def call(self, pid: str, tool: ToolHandle | str, args: dict[str, Any]) -> ToolCallResult: |
| #277 | try: |
| #278 | asyncio.get_running_loop() |
| #279 | except RuntimeError: |
| #280 | return asyncio.run(self.acall(pid, tool, args)) |
| #281 | raise RuntimeError("Cannot call ToolBroker.call() inside a running event loop. Use await acall(...).") |
| #282 | |
| #283 | async def acall(self, pid: str, tool: ToolHandle | str, args: dict[str, Any]) -> ToolCallResult: |
| #284 | handle = self.resolve(tool, pid=pid) |
| #285 | resource = f"tool:{handle.tool_id}" |
| #286 | if not self._process_has_tool(pid, handle): |
| #287 | # The process tool table gates only LLM-facing tool visibility. |
| #288 | # Host resources are checked by the primitive each tool calls into. |
| #289 | call_id = new_id("tcall") |
| #290 | error = f"tool is not in process tool table: {handle.name}" |
| #291 | self.events.emit( |
| #292 | EventType.TOOL_FAILED, |
| #293 | source=resource, |
| #294 | target=pid, |
| #295 | payload={"call_id": call_id, "error": error, "policy_decision": "deny"}, |
| #296 | ) |
| #297 | self.audit.record( |
| #298 | actor=pid, |
| #299 | action="tool.call", |
| #300 | target=resource, |
| #301 | decision={ |
| #302 | "ok": False, |
| #303 | "tool": handle.name, |
| #304 | "policy_decision": "deny", |
| #305 | "policy_reason": "tool_not_in_process_table", |
| #306 | }, |
| #307 | ) |
| #308 | return ToolCallResult(call_id=call_id, tool_id=handle.tool_id, result_handle=None, payload=None, ok=False, error=error) |
| #309 | |
| #310 | call_id = new_id("tcall") |
| #311 | self.events.emit( |
| #312 | EventType.TOOL_CALLED, |
| #313 | source=pid, |
| #314 | target=resource, |
| #315 | payload={"call_id": call_id, "args": args}, |
| #316 | ) |
| #317 | try: |
| #318 | jit_session: LibOSSyscallSession | None = None |
| #319 | if handle.tool_id in self._tools: |
| #320 | tool_result = await self._tools[handle.tool_id].ainvoke(args, self._context(pid, handle, call_id)) |
| #321 | if not tool_result.ok: |
| #322 | error_message = tool_result.error.message if tool_result.error else tool_result.content |
| #323 | self.events.emit( |
| #324 | EventType.TOOL_FAILED, |
| #325 | source=resource, |
| #326 | target=pid, |
| #327 | payload={"call_id": call_id, "error": error_message, "tool_result": tool_result.model_dump(mode="json")}, |
| #328 | ) |
| #329 | self.audit.record( |
| #330 | actor=pid, |
| #331 | action="tool.call", |
| #332 | target=resource, |
| #333 | decision={ |
| #334 | "ok": False, |
| #335 | "tool": handle.name, |
| #336 | "policy_decision": "allow", |
| #337 | "tool_result": tool_result.model_dump(mode="json"), |
| #338 | }, |
| #339 | ) |
| #340 | return ToolCallResult( |
| #341 | call_id=call_id, |
| #342 | tool_id=handle.tool_id, |
| #343 | result_handle=None, |
| #344 | payload=tool_result.model_dump(mode="json"), |
| #345 | ok=False, |
| #346 | error=error_message, |
| #347 | ) |
| #348 | payload = tool_result.data |
| #349 | result_payload = { |
| #350 | "tool_id": handle.tool_id, |
| #351 | "tool_name": handle.name, |
| #352 | "result": payload, |
| #353 | "content": tool_result.content, |
| #354 | "artifacts": [artifact.model_dump(mode="json") for artifact in tool_result.artifacts], |
| #355 | "metadata": tool_result.metadata, |
| #356 | } |
| #357 | elif handle.tool_id in self._jit_sources: |
| #358 | runtime = getattr(self, "runtime", None) |
| #359 | if runtime is None: |
| #360 | raise RuntimeError("Runtime is unavailable for Deno JIT syscall execution.") |
| #361 | jit_session = LibOSSyscallSession(runtime, pid, config=self.config) |
| #362 | payload = await self.sandbox.arun_source( |
| #363 | self._jit_sources[handle.tool_id], |
| #364 | args, |
| #365 | pid=pid, |
| #366 | syscall_handler=jit_session.handle, |
| #367 | ) |
| #368 | result_payload = {"tool_id": handle.tool_id, "tool_name": handle.name, "result": payload} |
| #369 | else: |
| #370 | raise NotFound(f"tool implementation not loaded: {handle.tool_id}") |
| #371 | except HumanApprovalRequired as exc: |
| #372 | # Do not convert this into a ToolCallResult: the LLM quantum has not |
| #373 | # completed and must be resumed after the human decision. |
| #374 | self.audit.record( |
| #375 | actor=pid, |
| #376 | action="tool.call_waiting_human", |
| #377 | target=resource, |
| #378 | decision={ |
| #379 | "ok": False, |
| #380 | "tool": handle.name, |
| #381 | "policy_decision": "require_human_approval", |
| #382 | "request_id": exc.request_id, |
| #383 | }, |
| #384 | ) |
| #385 | raise |
| #386 | except ProcessWaitRequired as exc: |
| #387 | self.audit.record( |
| #388 | actor=pid, |
| #389 | action="tool.call_waiting_process", |
| #390 | target=resource, |
| #391 | decision={ |
| #392 | "ok": False, |
| #393 | "tool": handle.name, |
| #394 | "policy_decision": "wait_for_child", |
| #395 | "child_pid": exc.child_pid, |
| #396 | }, |
| #397 | ) |
| #398 | raise |
| #399 | except ProcessMessageWaitRequired as exc: |
| #400 | self.audit.record( |
| #401 | actor=pid, |
| #402 | action="tool.call_waiting_message", |
| #403 | target=resource, |
| #404 | decision={ |
| #405 | "ok": False, |
| #406 | "tool": handle.name, |
| #407 | "policy_decision": "wait_for_process_message", |
| #408 | "recipient_pid": exc.recipient_pid, |
| #409 | "filters": exc.filters, |
| #410 | }, |
| #411 | ) |
| #412 | raise |
| #413 | except Exception as exc: |
| #414 | self.events.emit( |
| #415 | EventType.TOOL_FAILED, |
| #416 | source=resource, |
| #417 | target=pid, |
| #418 | payload={"call_id": call_id, "error": str(exc)}, |
| #419 | ) |
| #420 | self.audit.record( |
| #421 | actor=pid, |
| #422 | action="tool.call", |
| #423 | target=resource, |
| #424 | decision={"ok": False, "tool": handle.name, "policy_decision": "allow", "error": str(exc)}, |
| #425 | ) |
| #426 | return ToolCallResult(call_id=call_id, tool_id=handle.tool_id, result_handle=None, payload=None, ok=False, error=str(exc)) |
| #427 | |
| #428 | result_handle = self.memory.create_object( |
| #429 | pid=pid, |
| #430 | object_type=ObjectType.TOOL_RESULT, |
| #431 | payload=result_payload, |
| #432 | metadata=ObjectMetadata(title=f"Tool result: {handle.name}", tags=["tool_result", handle.name]), |
| #433 | immutable=True, |
| #434 | ) |
| #435 | self.events.emit( |
| #436 | EventType.TOOL_COMPLETED, |
| #437 | source=resource, |
| #438 | target=pid, |
| #439 | payload={"call_id": call_id, "result_oid": result_handle.oid}, |
| #440 | ) |
| #441 | self.audit.record( |
| #442 | actor=pid, |
| #443 | action="tool.call", |
| #444 | target=resource, |
| #445 | output_refs=[result_handle.oid], |
| #446 | decision={"ok": True, "tool": handle.name, "policy_decision": "allow"}, |
| #447 | ) |
| #448 | if jit_session is not None: |
| #449 | await jit_session.apply_deferred_lifecycle(result_handle) |
| #450 | return ToolCallResult( |
| #451 | call_id=call_id, |
| #452 | tool_id=handle.tool_id, |
| #453 | result_handle=result_handle, |
| #454 | payload=payload, |
| #455 | ok=True, |
| #456 | ) |
| #457 | |
| #458 | def resolve(self, tool: ToolHandle | str, pid: str | None = None) -> ToolHandle: |
| #459 | if isinstance(tool, ToolHandle): |
| #460 | return tool |
| #461 | if pid is not None: |
| #462 | process = self.store.get_process(pid) |
| #463 | if process is not None and tool in process.tool_table: |
| #464 | tool_id = process.tool_table[tool] |
| #465 | if tool_id in self._handles: |
| #466 | return self._handles[tool_id] |
| #467 | if tool in self._handles: |
| #468 | return self._handles[tool] |
| #469 | if tool in self._tool_ids_by_name: |
| #470 | return self._handles[self._tool_ids_by_name[tool]] |
| #471 | for row in self.store.list_tools(): |
| #472 | if row["tool_id"] == tool or row["name"] == tool: |
| #473 | if row["tool_id"] not in self._tools and row["tool_id"] not in self._jit_sources: |
| #474 | raise NotFound(f"tool implementation not loaded: {row['tool_id']}") |
| #475 | handle = ToolHandle(tool_id=row["tool_id"], name=row["name"], capability_id=None, scope=row["scope"]) |
| #476 | self._handles[handle.tool_id] = handle |
| #477 | self._tool_ids_by_name.setdefault(handle.name, handle.tool_id) |
| #478 | return handle |
| #479 | raise NotFound(f"tool not found: {tool}") |
| #480 | |
| #481 | def list(self) -> builtins.list[dict[str, Any]]: |
| #482 | return self.store.list_tools() |
| #483 | |
| #484 | def visible_tools(self, pid: str) -> builtins.list[dict[str, Any]]: |
| #485 | visible_ids = self._visible_tool_ids(pid) |
| #486 | return [row for row in self.store.list_tools() if row["tool_id"] in visible_ids] |
| #487 | |
| #488 | def openai_tool_schemas(self, pid: str | None = None) -> builtins.list[dict[str, Any]]: |
| #489 | tool_ids = self._visible_tool_ids(pid) if pid is not None else set(self._tools) |
| #490 | schemas: builtins.list[dict[str, Any]] = [] |
| #491 | for tool_id in tool_ids: |
| #492 | if tool_id in self._tools: |
| #493 | schemas.append(self._tools[tool_id].to_openai_chat_tool()) |
| #494 | continue |
| #495 | if tool_id not in self._jit_sources: |
| #496 | continue |
| #497 | spec = self.store.get_tool_spec(tool_id) |
| #498 | if spec is None: |
| #499 | continue |
| #500 | schemas.append( |
| #501 | { |
| #502 | "type": "function", |
| #503 | "function": { |
| #504 | "name": spec.name, |
| #505 | "description": spec.description, |
| #506 | "parameters": spec.input_schema, |
| #507 | }, |
| #508 | } |
| #509 | ) |
| #510 | return schemas |
| #511 | |
| #512 | def _context(self, pid: str, handle: ToolHandle, call_id: str) -> ToolContext: |
| #513 | tool = self._tools[handle.tool_id] |
| #514 | return ToolContext( |
| #515 | trace_id=call_id, |
| #516 | call_id=call_id, |
| #517 | pid=pid, |
| #518 | workspace_id=str(self.workspace_root), |
| #519 | runtime=getattr(self, "runtime", None), |
| #520 | granted_permissions=set(tool.policy.permissions), |
| #521 | metadata={ |
| #522 | "tool_id": handle.tool_id, |
| #523 | "tool_name": handle.name, |
| #524 | "confirmed": True, |
| #525 | }, |
| #526 | ) |
| #527 | |
| #528 | def _get_candidate(self, candidate_id: str) -> ToolCandidate: |
| #529 | candidate = self.store.get_tool_candidate(candidate_id) |
| #530 | if candidate is None: |
| #531 | raise NotFound(f"tool candidate not found: {candidate_id}") |
| #532 | return candidate |
| #533 | |
| #534 | def _require_candidate_owner(self, candidate: ToolCandidate, pid: str) -> None: |
| #535 | if candidate.pid != pid: |
| #536 | raise ValidationError( |
| #537 | f"tool candidate {candidate.candidate_id} belongs to process {candidate.pid}, not {pid}" |
| #538 | ) |
| #539 | |
| #540 | def _name_collides_with_static_tool(self, name: str) -> bool: |
| #541 | mapped = self._tool_ids_by_name.get(name) |
| #542 | if mapped in self._tools: |
| #543 | return True |
| #544 | return any(row["name"] == name and not bool(row["ephemeral"]) for row in self.store.list_tools()) |
| #545 | |
| #546 | def _process_has_tool(self, pid: str, handle: ToolHandle) -> bool: |
| #547 | process = self.store.get_process(pid) |
| #548 | if process is None: |
| #549 | raise NotFound(f"process not found: {pid}") |
| #550 | return process.tool_table.get(handle.name) == handle.tool_id |
| #551 | |
| #552 | def _visible_tool_ids(self, pid: str) -> set[str]: |
| #553 | process = self.store.get_process(pid) |
| #554 | if process is None: |
| #555 | raise NotFound(f"process not found: {pid}") |
| #556 | return set(process.tool_table.values()) |
| #557 | |
| #558 | |
| #559 | def _stable_static_tool_id(name: str, digest_chars: int = _TOOL_DEFAULTS.static_tool_id_digest_chars) -> str: |
| #560 | digest = hashlib.sha256(name.encode("utf-8")).hexdigest()[:digest_chars] |
| #561 | return f"tool_static_{digest}" |
| #562 |