Initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""Native Runtime V2 exports."""
|
||||
|
||||
from .runtime import NativeRuntimeV2
|
||||
|
||||
__all__ = ["NativeRuntimeV2"]
|
||||
@@ -0,0 +1,823 @@
|
||||
"""Permission helpers for Native Runtime V2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from opc.core.config import PermissionsV2Config, get_opc_home
|
||||
from opc.core.models import PermissionResolution, PermissionScope, RiskLevel, RuntimePermissionDecision
|
||||
from opc.llm.retry import LLMRetryError, call_llm_json_with_retry
|
||||
from opc.layer2_organization.data_acquisition_policy import (
|
||||
ACQUISITION_SHELL_PREFIXES,
|
||||
is_projection_scoped_acquisition_shell_command,
|
||||
)
|
||||
from opc.layer4_tools.registry import ToolDefinition
|
||||
|
||||
|
||||
_DEFAULT_PATH_KEYS = (
|
||||
"path",
|
||||
"file_path",
|
||||
"directory",
|
||||
"working_directory",
|
||||
"target_output_dir",
|
||||
"workspace_path",
|
||||
)
|
||||
_DEFAULT_COMMAND_KEYS = ("command", "cmd")
|
||||
_DEFAULT_URL_KEYS = ("url",)
|
||||
_READ_ONLY_PREFIXES = {
|
||||
"cat",
|
||||
"echo",
|
||||
"find",
|
||||
"git diff",
|
||||
"git log",
|
||||
"git show",
|
||||
"git status",
|
||||
"head",
|
||||
"ls",
|
||||
"node -v",
|
||||
"npm -v",
|
||||
"pwd",
|
||||
"python -V",
|
||||
"python3 -V",
|
||||
"rg",
|
||||
"tail",
|
||||
"wc",
|
||||
}
|
||||
_RISKY_SHELL_KEYWORDS = (
|
||||
"curl ",
|
||||
"wget ",
|
||||
"invoke-webrequest",
|
||||
"invoke-restmethod",
|
||||
"mv ",
|
||||
"cp ",
|
||||
"rm ",
|
||||
"del ",
|
||||
"remove-item",
|
||||
"git commit",
|
||||
"git push",
|
||||
"npm install",
|
||||
"pip install",
|
||||
"pnpm install",
|
||||
"cargo test",
|
||||
"pytest",
|
||||
"tee ",
|
||||
"sed -i",
|
||||
">",
|
||||
">>",
|
||||
)
|
||||
_ACQUISITION_SHELL_PREFIXES = {str(item) for item in ACQUISITION_SHELL_PREFIXES}
|
||||
_ANY_GRANT_VALUE = "*"
|
||||
|
||||
|
||||
class ToolPermissionResolver:
|
||||
"""Runtime permission gate with persisted session/project/global grants."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: PermissionsV2Config | None = None,
|
||||
*,
|
||||
store: Any = None,
|
||||
runtime_session_id: str = "",
|
||||
project_id: str = "default",
|
||||
llm: Any | None = None,
|
||||
) -> None:
|
||||
self.config = config or PermissionsV2Config()
|
||||
self.store = store
|
||||
self.runtime_session_id = runtime_session_id
|
||||
self.project_id = project_id or "default"
|
||||
self.llm = llm
|
||||
self._loaded = False
|
||||
self._session_grants: set[tuple[str, str, str, str, str]] = set()
|
||||
self._project_grants: set[tuple[str, str, str, str, str]] = set()
|
||||
self._global_grants: set[tuple[str, str, str, str, str]] = set()
|
||||
self._denial_counts: dict[str, int] = {}
|
||||
|
||||
async def warmup(self) -> None:
|
||||
if self._loaded or not self.store or not hasattr(self.store, "list_runtime_permission_grants"):
|
||||
self._loaded = True
|
||||
return
|
||||
session_rows = await self.store.list_runtime_permission_grants(
|
||||
runtime_session_id=self.runtime_session_id or None,
|
||||
scopes=["session"],
|
||||
)
|
||||
project_rows = await self.store.list_runtime_permission_grants(
|
||||
project_id=self.project_id,
|
||||
scopes=["project"],
|
||||
)
|
||||
global_rows = await self.store.list_runtime_permission_grants(scopes=["global"])
|
||||
self._session_grants = {self._grant_key_from_row(row) for row in session_rows}
|
||||
self._project_grants = {self._grant_key_from_row(row) for row in project_rows}
|
||||
self._global_grants = {self._grant_key_from_row(row) for row in global_rows}
|
||||
self._loaded = True
|
||||
|
||||
def _candidate_extractors(self) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...]]:
|
||||
keys = [str(item or "").strip() for item in self.config.candidate_extractors if str(item or "").strip()]
|
||||
if not keys:
|
||||
keys = [*_DEFAULT_PATH_KEYS, *_DEFAULT_COMMAND_KEYS, *_DEFAULT_URL_KEYS]
|
||||
path_keys = tuple(item for item in keys if item in _DEFAULT_PATH_KEYS)
|
||||
command_keys = tuple(item for item in keys if item in _DEFAULT_COMMAND_KEYS)
|
||||
url_keys = tuple(item for item in keys if item in _DEFAULT_URL_KEYS)
|
||||
return (
|
||||
path_keys or _DEFAULT_PATH_KEYS,
|
||||
command_keys or _DEFAULT_COMMAND_KEYS,
|
||||
url_keys or _DEFAULT_URL_KEYS,
|
||||
)
|
||||
|
||||
def _grant_key_from_row(self, row: dict[str, Any]) -> tuple[str, str, str, str, str]:
|
||||
tool_name = str(row.get("tool_name", "") or "").strip()
|
||||
candidate = str(row.get("candidate", "") or "").strip()
|
||||
metadata = dict(row.get("metadata", {}) or {})
|
||||
sandbox_mode = str(metadata.get("sandbox_mode", "") or "").strip() or _ANY_GRANT_VALUE
|
||||
allow_network = str(metadata.get("allow_network", "") or "").strip().lower() or _ANY_GRANT_VALUE
|
||||
workspace_class = str(metadata.get("workspace_class", "") or "").strip() or _ANY_GRANT_VALUE
|
||||
return self._grant_key(
|
||||
tool_name,
|
||||
candidate,
|
||||
sandbox_mode=sandbox_mode,
|
||||
allow_network=allow_network,
|
||||
workspace_class=workspace_class,
|
||||
)
|
||||
|
||||
def _grant_key(
|
||||
self,
|
||||
tool_name: str,
|
||||
candidate: str,
|
||||
*,
|
||||
sandbox_mode: str,
|
||||
allow_network: str,
|
||||
workspace_class: str,
|
||||
) -> tuple[str, str, str, str, str]:
|
||||
normalized = candidate.strip() or _ANY_GRANT_VALUE
|
||||
return (
|
||||
tool_name,
|
||||
normalized,
|
||||
sandbox_mode.strip() or _ANY_GRANT_VALUE,
|
||||
allow_network.strip().lower() or _ANY_GRANT_VALUE,
|
||||
workspace_class.strip() or _ANY_GRANT_VALUE,
|
||||
)
|
||||
|
||||
def _candidate(self, arguments: dict[str, Any] | None = None) -> str:
|
||||
if not arguments:
|
||||
return _ANY_GRANT_VALUE
|
||||
path_keys, command_keys, url_keys = self._candidate_extractors()
|
||||
for key in (*path_keys, *command_keys, *url_keys):
|
||||
value = str(arguments.get(key, "") or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return _ANY_GRANT_VALUE
|
||||
|
||||
def _grant_context(self, task: Any = None) -> tuple[str, str, str]:
|
||||
sandbox_mode = _ANY_GRANT_VALUE
|
||||
allow_network = _ANY_GRANT_VALUE
|
||||
workspace_class = _ANY_GRANT_VALUE
|
||||
if task is None:
|
||||
return sandbox_mode, allow_network, workspace_class
|
||||
metadata = getattr(task, "metadata", {}) or {}
|
||||
execution_context = dict(metadata.get("_execution_context", {}) or {})
|
||||
sandbox = dict(execution_context.get("sandbox", {}) or {})
|
||||
sandbox_mode = str(sandbox.get("mode", "") or "").strip() or _ANY_GRANT_VALUE
|
||||
allow_network = str(bool(sandbox.get("allow_network", True))).lower()
|
||||
workspace_root = (
|
||||
str(execution_context.get("workspace_root", "") or "").strip()
|
||||
or str(metadata.get("workspace_root", "") or "").strip()
|
||||
or str(metadata.get("comms_workspace_root", "") or "").strip()
|
||||
or str(metadata.get("target_output_dir", "") or "").strip()
|
||||
)
|
||||
workspace_class = "workspace" if workspace_root else "default"
|
||||
return sandbox_mode, allow_network, workspace_class
|
||||
|
||||
@staticmethod
|
||||
def _risk(value: Any, default: RiskLevel) -> RiskLevel:
|
||||
try:
|
||||
return RiskLevel(str(value or default.value))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_shell_tool(tool_name: str) -> bool:
|
||||
return tool_name in {"shell_exec", "python_exec", "git_commit"}
|
||||
|
||||
def _normalized_tool_set(self, values: list[str]) -> set[str]:
|
||||
return {str(item or "").strip() for item in values if str(item or "").strip()}
|
||||
|
||||
def _matches_path_rule(self, candidate: str, rules: list[str]) -> bool:
|
||||
if not candidate or candidate == "*":
|
||||
return False
|
||||
raw = str(candidate).strip()
|
||||
for rule in rules:
|
||||
token = str(rule or "").strip()
|
||||
if not token:
|
||||
continue
|
||||
if token == "*" or raw == token:
|
||||
return True
|
||||
try:
|
||||
rule_path = Path(token).resolve()
|
||||
candidate_path = Path(raw).resolve()
|
||||
except Exception:
|
||||
if raw.startswith(token.rstrip("\\/")):
|
||||
return True
|
||||
continue
|
||||
if candidate_path == rule_path or rule_path in candidate_path.parents:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _workspace_paths(self, task: Any = None) -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
metadata = getattr(task, "metadata", {}) or {} if task else {}
|
||||
for raw in (
|
||||
str(metadata.get("workspace_root", "") or "").strip(),
|
||||
str(metadata.get("comms_workspace_root", "") or "").strip(),
|
||||
str(metadata.get("output_root", "") or "").strip(),
|
||||
str(metadata.get("target_output_dir", "") or "").strip(),
|
||||
):
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
path = Path(raw).resolve()
|
||||
except Exception:
|
||||
continue
|
||||
if path not in roots:
|
||||
roots.append(path)
|
||||
try:
|
||||
memory_root = (Path(get_opc_home()) / "memory").resolve()
|
||||
if memory_root not in roots:
|
||||
roots.append(memory_root)
|
||||
except Exception:
|
||||
pass
|
||||
if not roots:
|
||||
try:
|
||||
roots.append(Path.cwd().resolve())
|
||||
except Exception:
|
||||
pass
|
||||
return roots
|
||||
|
||||
def _path_decision(
|
||||
self,
|
||||
tool: ToolDefinition,
|
||||
arguments: dict[str, Any] | None,
|
||||
task: Any = None,
|
||||
) -> RuntimePermissionDecision | None:
|
||||
if not arguments:
|
||||
return None
|
||||
path_keys, _, _ = self._candidate_extractors()
|
||||
candidate = ""
|
||||
for key in path_keys:
|
||||
value = str(arguments.get(key, "") or "").strip()
|
||||
if value:
|
||||
candidate = value
|
||||
break
|
||||
if not candidate:
|
||||
return None
|
||||
if self._matches_path_rule(candidate, self.config.denied_paths):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.DENY,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.HIGH,
|
||||
rationale="Target path matches a denied runtime permission rule.",
|
||||
source="permission_rules",
|
||||
)
|
||||
if self._matches_path_rule(candidate, self.config.allowed_paths):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.PROJECT,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Target path matches an explicit runtime allow rule.",
|
||||
source="permission_rules",
|
||||
)
|
||||
try:
|
||||
resolved = Path(candidate).resolve()
|
||||
except Exception:
|
||||
return None
|
||||
if tool.read_only:
|
||||
return None
|
||||
for root in self._workspace_paths(task):
|
||||
if resolved == root or root in resolved.parents:
|
||||
return None
|
||||
risk = RiskLevel.HIGH if self.config.sandbox_policy.treat_external_paths_as_high_risk else RiskLevel.MEDIUM
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ASK if self.config.fail_closed else PermissionResolution.DENY,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=risk,
|
||||
rationale="Target path is outside the current runtime workspace roots.",
|
||||
source="path_guard",
|
||||
metadata={"candidate": candidate},
|
||||
)
|
||||
|
||||
def _split_command_prefix(self, command: str) -> str:
|
||||
text = str(command or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
parts = shlex.split(text, posix=os.name != "nt")
|
||||
except Exception:
|
||||
parts = text.split()
|
||||
if not parts:
|
||||
return ""
|
||||
if len(parts) >= 2:
|
||||
return f"{parts[0]} {parts[1]}".strip()
|
||||
return parts[0]
|
||||
|
||||
def _matches_command_prefix(self, command: str, prefixes: list[str]) -> bool:
|
||||
raw = str(command or "").strip()
|
||||
prefix = self._split_command_prefix(raw)
|
||||
candidates = {raw, prefix}
|
||||
for item in prefixes:
|
||||
token = str(item or "").strip()
|
||||
if not token:
|
||||
continue
|
||||
if raw == token or prefix == token:
|
||||
return True
|
||||
if raw.startswith(f"{token} ") or prefix.startswith(f"{token} "):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _split_shell_command_segments(self, command: str) -> list[list[str]]:
|
||||
text = str(command or "").replace("\r\n", "\n").replace("\n", " ; ").strip()
|
||||
if not text:
|
||||
return []
|
||||
try:
|
||||
lexer = shlex.shlex(text, posix=os.name != "nt", punctuation_chars=";&|")
|
||||
lexer.whitespace_split = True
|
||||
lexer.commenters = ""
|
||||
tokens = list(lexer)
|
||||
except Exception:
|
||||
try:
|
||||
tokens = shlex.split(text, posix=os.name != "nt")
|
||||
except Exception:
|
||||
tokens = text.split()
|
||||
|
||||
segments: list[list[str]] = []
|
||||
current: list[str] = []
|
||||
for token in tokens:
|
||||
if token in {"&&", "||", ";", "|", "&"}:
|
||||
if current:
|
||||
segments.append(current)
|
||||
current = []
|
||||
continue
|
||||
current.append(token)
|
||||
if current:
|
||||
segments.append(current)
|
||||
return segments
|
||||
|
||||
def _command_has_redirection(self, command: str) -> bool:
|
||||
text = str(command or "").replace("\r\n", "\n").replace("\n", " ; ").strip()
|
||||
if not text:
|
||||
return False
|
||||
try:
|
||||
lexer = shlex.shlex(text, posix=os.name != "nt", punctuation_chars=";&|<>")
|
||||
lexer.whitespace_split = True
|
||||
lexer.commenters = ""
|
||||
tokens = list(lexer)
|
||||
except Exception:
|
||||
return any(marker in text for marker in (">", "<"))
|
||||
return any(token in {">", ">>", "<", "<<"} for token in tokens)
|
||||
|
||||
def _matches_safe_shell_prefix(self, command: str, prefixes: list[str]) -> bool:
|
||||
cleaned = " ".join(str(command or "").split()).strip()
|
||||
if not cleaned or self._command_has_redirection(cleaned):
|
||||
return False
|
||||
segments = self._split_shell_command_segments(cleaned)
|
||||
if len(segments) != 1:
|
||||
return False
|
||||
return self._matches_command_prefix(" ".join(segments[0]).strip(), prefixes)
|
||||
|
||||
def _shell_ast_reason(self, command: str) -> tuple[RiskLevel, str] | None:
|
||||
lowered = str(command or "").strip().lower()
|
||||
if not lowered:
|
||||
return None
|
||||
if lowered in _READ_ONLY_PREFIXES or self._matches_command_prefix(lowered, list(_READ_ONLY_PREFIXES)):
|
||||
return RiskLevel.LOW, "Command matches a read-only shell prefix."
|
||||
for keyword in _RISKY_SHELL_KEYWORDS:
|
||||
if keyword in lowered:
|
||||
risk = RiskLevel.HIGH
|
||||
if keyword in {"curl ", "wget ", "invoke-webrequest", "invoke-restmethod"} and self.config.sandbox_policy.treat_network_as_risky:
|
||||
risk = RiskLevel.CRITICAL
|
||||
return risk, f"Command contains risky shell operation `{keyword.strip()}`."
|
||||
return RiskLevel.MEDIUM, "Shell AST classifier could not prove the command is read-only."
|
||||
|
||||
def _shell_decision(
|
||||
self,
|
||||
tool: ToolDefinition,
|
||||
arguments: dict[str, Any] | None,
|
||||
*,
|
||||
task: Any = None,
|
||||
) -> RuntimePermissionDecision | None:
|
||||
if not self._looks_like_shell_tool(tool.name) or not arguments:
|
||||
return None
|
||||
_, command_keys, _ = self._candidate_extractors()
|
||||
command = ""
|
||||
for key in command_keys:
|
||||
value = str(arguments.get(key, "") or "").strip()
|
||||
if value:
|
||||
command = value
|
||||
break
|
||||
if not command:
|
||||
return None
|
||||
projection_scoped_low_risk = is_projection_scoped_acquisition_shell_command(
|
||||
command=command,
|
||||
task=task,
|
||||
working_directory=str(arguments.get("working_directory", "") or arguments.get("workdir", "") or "").strip(),
|
||||
target_output_dir=str(getattr(task, "metadata", {}).get("target_output_dir", "") or "").strip() if task else "",
|
||||
)
|
||||
if projection_scoped_low_risk:
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Command matches a work-item-scoped acquisition prefix inside the assigned workspace.",
|
||||
source="shell_prefix",
|
||||
metadata={"candidate": command},
|
||||
)
|
||||
for pattern in self.config.dangerous_shell_patterns:
|
||||
if pattern and re.search(pattern, command, flags=re.IGNORECASE):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ASK,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.CRITICAL,
|
||||
rationale=f"Command matched dangerous shell pattern `{pattern}`.",
|
||||
source="shell_pattern",
|
||||
metadata={"candidate": command},
|
||||
)
|
||||
filtered_safe_prefixes = [
|
||||
item for item in self.config.safe_shell_prefixes
|
||||
if str(item or "").strip() not in _ACQUISITION_SHELL_PREFIXES
|
||||
]
|
||||
if self._matches_safe_shell_prefix(command, filtered_safe_prefixes):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Command matches a safe shell prefix.",
|
||||
source="shell_prefix",
|
||||
metadata={"candidate": command},
|
||||
)
|
||||
if self._matches_command_prefix(command, self.config.ask_shell_prefixes):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ASK,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.MEDIUM,
|
||||
rationale="Command matches an ask-first shell prefix.",
|
||||
source="shell_prefix",
|
||||
metadata={"candidate": command},
|
||||
)
|
||||
if self.config.shell_ast_validation:
|
||||
risk, rationale = self._shell_ast_reason(command) or (RiskLevel.MEDIUM, "Shell command requires manual review.")
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW if risk == RiskLevel.LOW else PermissionResolution.ASK,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=risk,
|
||||
rationale=rationale,
|
||||
source="shell_ast",
|
||||
metadata={"candidate": command},
|
||||
)
|
||||
if tool.requires_confirmation or self.config.fail_closed:
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ASK,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.HIGH if not tool.read_only else RiskLevel.MEDIUM,
|
||||
rationale="Shell command requires explicit approval under runtime_v2.",
|
||||
source="shell_guard",
|
||||
metadata={"candidate": command},
|
||||
)
|
||||
return None
|
||||
|
||||
def _candidate_matches(self, candidate: str, granted_candidate: str) -> bool:
|
||||
if granted_candidate == _ANY_GRANT_VALUE:
|
||||
return True
|
||||
if candidate == granted_candidate:
|
||||
return True
|
||||
return candidate.startswith(granted_candidate.rstrip("\\/"))
|
||||
|
||||
@staticmethod
|
||||
def _sandbox_rank(mode: str) -> int:
|
||||
return {
|
||||
"workspace-write": 1,
|
||||
"elevated": 2,
|
||||
"off": 3,
|
||||
}.get(str(mode or "").strip().lower(), 0)
|
||||
|
||||
def _match_grant(self, grants: set[tuple[str, str, str, str, str]], tool_name: str, candidate: str, *, task: Any = None) -> bool:
|
||||
if not grants:
|
||||
return False
|
||||
sandbox_mode, allow_network, workspace_class = self._grant_context(task)
|
||||
for grant_tool, grant_candidate, grant_sandbox_mode, grant_allow_network, grant_workspace_class in grants:
|
||||
if grant_tool != tool_name:
|
||||
continue
|
||||
if not self._candidate_matches(candidate, grant_candidate):
|
||||
continue
|
||||
if grant_sandbox_mode not in {_ANY_GRANT_VALUE, sandbox_mode}:
|
||||
if not (
|
||||
self.config.guardian.cache_upgrade_context
|
||||
and self._sandbox_rank(sandbox_mode) >= self._sandbox_rank(grant_sandbox_mode)
|
||||
):
|
||||
continue
|
||||
if grant_allow_network not in {_ANY_GRANT_VALUE, allow_network}:
|
||||
continue
|
||||
if grant_workspace_class not in {_ANY_GRANT_VALUE, workspace_class}:
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
def _denial_memory_key(self, tool_name: str, arguments: dict[str, Any] | None) -> str:
|
||||
return f"{tool_name}:{self._candidate(arguments)}"
|
||||
|
||||
def record_denial(self, tool_name: str, arguments: dict[str, Any] | None) -> None:
|
||||
if not self.config.denial_memory.enabled:
|
||||
return
|
||||
key = self._denial_memory_key(tool_name, arguments)
|
||||
self._denial_counts[key] = self._denial_counts.get(key, 0) + 1
|
||||
|
||||
def _repeat_denial_decision(self, tool_name: str, arguments: dict[str, Any] | None) -> RuntimePermissionDecision | None:
|
||||
if not self.config.denial_memory.enabled:
|
||||
return None
|
||||
key = self._denial_memory_key(tool_name, arguments)
|
||||
repeats = self._denial_counts.get(key, 0)
|
||||
if repeats < max(1, self.config.denial_memory.repeat_threshold):
|
||||
return None
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.DENY,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.HIGH,
|
||||
rationale="Repeated denial memory indicates this action should stop and ask for a new plan.",
|
||||
source="denial_memory",
|
||||
metadata={"repeated_denials": repeats},
|
||||
)
|
||||
|
||||
def build_blocked_result(
|
||||
self,
|
||||
decision: RuntimePermissionDecision,
|
||||
*,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
action = "reject" if decision.resolution == PermissionResolution.DENY else "require_input"
|
||||
candidate = self._candidate(arguments)
|
||||
return {
|
||||
"error": decision.rationale or f"Runtime permission blocked `{tool_name}`.",
|
||||
"success": False,
|
||||
"approval": {
|
||||
"action": action,
|
||||
"risk_level": decision.risk_level.value,
|
||||
"policy_source": decision.source,
|
||||
"scope": decision.scope.value,
|
||||
"candidate": candidate,
|
||||
"explanation": decision.rationale,
|
||||
"metadata": dict(decision.metadata or {}),
|
||||
},
|
||||
"permission_context": {
|
||||
"tool_name": tool_name,
|
||||
"candidate": candidate,
|
||||
"resolution": decision.resolution.value,
|
||||
"risk_level": decision.risk_level.value,
|
||||
"source": decision.source,
|
||||
},
|
||||
}
|
||||
|
||||
def predicted_decision(
|
||||
self,
|
||||
tool: ToolDefinition | None,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
task: Any = None,
|
||||
) -> RuntimePermissionDecision:
|
||||
if tool is None:
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ASK if self.config.fail_closed else PermissionResolution.DENY,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.HIGH,
|
||||
rationale="Unknown tool requires manual review.",
|
||||
source="runtime_prediction",
|
||||
)
|
||||
tool_name = tool.name
|
||||
candidate = self._candidate(arguments)
|
||||
repeated_denial = self._repeat_denial_decision(tool_name, arguments)
|
||||
if repeated_denial is not None:
|
||||
return repeated_denial
|
||||
if tool_name in self._normalized_tool_set(self.config.deny_tools):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.DENY,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.HIGH,
|
||||
rationale="Tool is explicitly denied by runtime permission rules.",
|
||||
source="permission_rules",
|
||||
)
|
||||
if self._match_grant(self._session_grants, tool_name, candidate, task=task):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.SESSION,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Allowed by runtime session grant.",
|
||||
source="runtime_session_grant",
|
||||
)
|
||||
if self._match_grant(self._project_grants, tool_name, candidate, task=task):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.PROJECT,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Allowed by persisted project grant.",
|
||||
source="runtime_project_grant",
|
||||
)
|
||||
if self._match_grant(self._global_grants, tool_name, candidate, task=task):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.GLOBAL,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Allowed by persisted global grant.",
|
||||
source="runtime_global_grant",
|
||||
)
|
||||
if tool_name in self._normalized_tool_set(self.config.allow_tools):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.PROJECT,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Tool is explicitly allowed by runtime permission rules.",
|
||||
source="permission_rules",
|
||||
)
|
||||
path_decision = self._path_decision(tool, arguments, task=task)
|
||||
if path_decision is not None:
|
||||
return path_decision
|
||||
shell_decision = self._shell_decision(tool, arguments, task=task)
|
||||
if shell_decision is not None:
|
||||
return shell_decision
|
||||
if tool.requires_confirmation:
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ASK,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.MEDIUM,
|
||||
rationale="Tool is marked as requiring confirmation.",
|
||||
source="runtime_prediction",
|
||||
metadata={"candidate": candidate},
|
||||
)
|
||||
if self.config.guardian.enabled and self.config.guardian.auto_allow_read_only and tool.read_only:
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Guardian pre-check marked the tool as deterministic read-only.",
|
||||
source="guardian",
|
||||
metadata={"candidate": candidate},
|
||||
)
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="No runtime permission warning triggered.",
|
||||
source="runtime_prediction",
|
||||
metadata={"candidate": candidate},
|
||||
)
|
||||
|
||||
async def refine_decision(
|
||||
self,
|
||||
decision: RuntimePermissionDecision,
|
||||
*,
|
||||
tool: ToolDefinition | None,
|
||||
arguments: dict[str, Any] | None,
|
||||
task: Any = None,
|
||||
) -> RuntimePermissionDecision:
|
||||
if decision.resolution != PermissionResolution.ASK:
|
||||
return decision
|
||||
if not self.config.classifier_enabled or not self.config.llm_classifier_model or self.llm is None or tool is None:
|
||||
return decision
|
||||
payload = {
|
||||
"tool_name": tool.name,
|
||||
"arguments": arguments or {},
|
||||
"candidate": self._candidate(arguments),
|
||||
"project_id": getattr(task, "project_id", self.project_id),
|
||||
"heuristic_rationale": decision.rationale,
|
||||
}
|
||||
def _validate_classifier(parsed: Any) -> str | None:
|
||||
if not isinstance(parsed, dict):
|
||||
return "Top-level response must be a JSON object."
|
||||
try:
|
||||
score_val = float(parsed.get("score", 0.5) or 0.5)
|
||||
except (TypeError, ValueError):
|
||||
return "`score` must be a number between 0 and 1."
|
||||
if score_val < 0 or score_val > 1:
|
||||
return "`score` must be a number between 0 and 1."
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = await call_llm_json_with_retry(
|
||||
self.llm,
|
||||
system=(
|
||||
"You are a runtime permission classifier.\n"
|
||||
"Return strict JSON with keys `score` and `reason`.\n"
|
||||
"`score` is a float between 0 and 1 where 0 is clearly safe and 1 is clearly unsafe.\n"
|
||||
"Classify file mutation, shell execution, path escape risk, and network side effects conservatively."
|
||||
),
|
||||
payload=payload,
|
||||
task_type="quick_tasks",
|
||||
validator=_validate_classifier,
|
||||
label="runtime_permission_classifier",
|
||||
)
|
||||
except LLMRetryError:
|
||||
return decision
|
||||
score = float(parsed.get("score", 0.5) or 0.5)
|
||||
reason = str(parsed.get("reason", "") or decision.rationale)
|
||||
thresholds = self.config.classifier_thresholds
|
||||
if score <= thresholds.allow:
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=decision.scope,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale=reason or "Classifier marked the action safe.",
|
||||
source="llm_classifier",
|
||||
metadata={**dict(decision.metadata or {}), "classifier_score": score},
|
||||
)
|
||||
if score >= thresholds.deny:
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.DENY,
|
||||
scope=decision.scope,
|
||||
risk_level=RiskLevel.HIGH,
|
||||
rationale=reason or "Classifier marked the action unsafe.",
|
||||
source="llm_classifier",
|
||||
metadata={**dict(decision.metadata or {}), "classifier_score": score},
|
||||
)
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ASK,
|
||||
scope=decision.scope,
|
||||
risk_level=RiskLevel.MEDIUM if score < thresholds.ask else RiskLevel.HIGH,
|
||||
rationale=reason or decision.rationale,
|
||||
source="llm_classifier",
|
||||
metadata={**dict(decision.metadata or {}), "classifier_score": score},
|
||||
)
|
||||
|
||||
def decision_from_result(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any] | None,
|
||||
result: dict[str, Any],
|
||||
) -> RuntimePermissionDecision:
|
||||
approval = dict(result.get("approval", {}) or {})
|
||||
action = str(approval.get("action", "") or "").strip().lower()
|
||||
if action in {"require_input", "escalate"}:
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ASK,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=self._risk(approval.get("risk_level"), RiskLevel.MEDIUM),
|
||||
rationale=str(result.get("error", "") or "Awaiting explicit permission."),
|
||||
source="approval_engine",
|
||||
metadata=approval,
|
||||
)
|
||||
if action == "reject":
|
||||
self.record_denial(tool_name, arguments)
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.DENY,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=self._risk(approval.get("risk_level"), RiskLevel.HIGH),
|
||||
rationale=str(result.get("error", "") or "Permission denied."),
|
||||
source="approval_engine",
|
||||
metadata=approval,
|
||||
)
|
||||
human_reply = str(approval.get("human_reply") or result.get("human_reply") or "").strip().lower()
|
||||
candidate = self._candidate(arguments)
|
||||
grant = self._grant_key(
|
||||
tool_name,
|
||||
candidate,
|
||||
sandbox_mode=_ANY_GRANT_VALUE,
|
||||
allow_network=_ANY_GRANT_VALUE,
|
||||
workspace_class=_ANY_GRANT_VALUE,
|
||||
)
|
||||
if human_reply == "approve_session":
|
||||
self._session_grants.add(grant)
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.SESSION,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Approved for this runtime session.",
|
||||
source="human_escalation",
|
||||
metadata=approval,
|
||||
)
|
||||
if human_reply == "always_project":
|
||||
self._project_grants.add(grant)
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.PROJECT,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Approved for this project.",
|
||||
source="human_escalation",
|
||||
metadata=approval,
|
||||
)
|
||||
if human_reply == "always_global":
|
||||
self._global_grants.add(grant)
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.GLOBAL,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Approved globally.",
|
||||
source="human_escalation",
|
||||
metadata=approval,
|
||||
)
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Tool execution allowed.",
|
||||
source="approval_engine",
|
||||
metadata=approval,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,514 @@
|
||||
"""Streaming-friendly tool executor for Native Runtime V2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from opc.core.models import PermissionResolution
|
||||
from opc.layer3_agent.runtime_v2.permissions import ToolPermissionResolver
|
||||
from opc.layer3_agent.runtime_v2.tool_hooks import RuntimeToolHookBus, RuntimeToolHookContext
|
||||
from opc.layer3_agent.runtime_v2.tool_planner import ToolBatch, ToolPlanner
|
||||
from opc.layer4_tools.registry import ToolRegistry
|
||||
|
||||
|
||||
RuntimeToolHandler = Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]]
|
||||
RuntimeEventCallback = Callable[[str, dict[str, Any]], Awaitable[None]]
|
||||
_HEARTBEAT_INTERVAL_SECONDS = 1.0
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _result_summary(result: dict[str, Any], *, limit: int = 240) -> str:
|
||||
if not result:
|
||||
return ""
|
||||
if result.get("error"):
|
||||
summary = str(result.get("error", "") or "").strip()
|
||||
else:
|
||||
payload = result.get("result", {})
|
||||
summary = ""
|
||||
if isinstance(payload, dict):
|
||||
for key in ("summary", "rendered", "stdout", "stderr", "content", "message"):
|
||||
value = payload.get(key)
|
||||
if value:
|
||||
summary = str(value).strip()
|
||||
break
|
||||
if not summary:
|
||||
summary = json.dumps(result, ensure_ascii=False, default=str)
|
||||
if len(summary) <= limit:
|
||||
return summary
|
||||
return summary[:limit].rstrip() + "..."
|
||||
|
||||
|
||||
class StreamingToolExecutor:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
registry: ToolRegistry,
|
||||
planner: ToolPlanner,
|
||||
permission_resolver: ToolPermissionResolver,
|
||||
hook_bus: RuntimeToolHookBus | None = None,
|
||||
runtime_tool_handler: RuntimeToolHandler | None = None,
|
||||
emit_event: RuntimeEventCallback | None = None,
|
||||
max_parallel_read_tools: int = 6,
|
||||
converge_on_parallel_failure: bool = True,
|
||||
) -> None:
|
||||
self.registry = registry
|
||||
self.planner = planner
|
||||
self.permission_resolver = permission_resolver
|
||||
self.hook_bus = hook_bus
|
||||
self.runtime_tool_handler = runtime_tool_handler
|
||||
self.emit_event = emit_event
|
||||
self.max_parallel_read_tools = max(1, int(max_parallel_read_tools or 1))
|
||||
self.converge_on_parallel_failure = bool(converge_on_parallel_failure)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
tool_calls: list[dict[str, Any]],
|
||||
*,
|
||||
task: Any = None,
|
||||
on_progress: Any = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
ordered_results: list[dict[str, Any]] = []
|
||||
for batch in self.planner.partition(tool_calls):
|
||||
batch_id = f"tb_{uuid.uuid4().hex[:12]}"
|
||||
batch_started_at_ms = _now_ms()
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"tool_batch_started",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"started_at_ms": batch_started_at_ms,
|
||||
"concurrency_safe": batch.concurrency_safe,
|
||||
"tool_names": [str(call.get("function", "") or "") for call in batch.calls],
|
||||
"tool_call_ids": [str(call.get("id", "") or "") for call in batch.calls],
|
||||
},
|
||||
)
|
||||
if batch.concurrency_safe:
|
||||
batch_results = await self._run_parallel(batch, task=task, on_progress=on_progress, batch_id=batch_id)
|
||||
else:
|
||||
batch_results: list[dict[str, Any]] = []
|
||||
for call in batch.calls:
|
||||
batch_results.append(await self._run_one(call, task=task, on_progress=on_progress, batch_id=batch_id))
|
||||
ordered_results.extend(batch_results)
|
||||
if self.emit_event:
|
||||
batch_completed_at_ms = _now_ms()
|
||||
await self.emit_event(
|
||||
"tool_batch_completed",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"started_at_ms": batch_started_at_ms,
|
||||
"completed_at_ms": batch_completed_at_ms,
|
||||
"elapsed_ms": max(0, batch_completed_at_ms - batch_started_at_ms),
|
||||
"concurrency_safe": batch.concurrency_safe,
|
||||
"success": all(bool(item.get("result", {}).get("success", True)) for item in batch_results),
|
||||
"tool_count": len(batch_results),
|
||||
},
|
||||
)
|
||||
return ordered_results
|
||||
|
||||
async def _run_parallel(
|
||||
self,
|
||||
batch: ToolBatch,
|
||||
*,
|
||||
task: Any = None,
|
||||
on_progress: Any = None,
|
||||
batch_id: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
semaphore = asyncio.Semaphore(self.max_parallel_read_tools)
|
||||
batch_state: dict[str, Any] = {
|
||||
"cascade_event": asyncio.Event(),
|
||||
"failed_call_id": "",
|
||||
"failed_tool_name": "",
|
||||
}
|
||||
|
||||
async def _wrapped(call: dict[str, Any]) -> dict[str, Any]:
|
||||
async with semaphore:
|
||||
if self.converge_on_parallel_failure and batch_state["cascade_event"].is_set():
|
||||
return await self._build_converged_result(call, batch_state, batch_id=batch_id)
|
||||
result = await self._run_one(call, task=task, on_progress=on_progress, batch_state=batch_state, batch_id=batch_id)
|
||||
if self.converge_on_parallel_failure and self._should_converge_batch(result):
|
||||
batch_state["failed_call_id"] = str(call.get("id", "") or "")
|
||||
batch_state["failed_tool_name"] = str(call.get("function", "") or "")
|
||||
batch_state["cascade_event"].set()
|
||||
return result
|
||||
|
||||
return list(await asyncio.gather(*[_wrapped(call) for call in batch.calls]))
|
||||
|
||||
async def _run_one(
|
||||
self,
|
||||
call: dict[str, Any],
|
||||
*,
|
||||
task: Any = None,
|
||||
on_progress: Any = None,
|
||||
batch_state: dict[str, Any] | None = None,
|
||||
batch_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
tool_name = str(call.get("function", "") or "")
|
||||
arguments = dict(call.get("arguments", {}) or {})
|
||||
tool = self.registry.get(tool_name)
|
||||
predicted = self.permission_resolver.predicted_decision(tool, arguments, task=task)
|
||||
started_at_ms = _now_ms()
|
||||
started_at_monotonic = time.monotonic()
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"permission_predicted",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"arguments": arguments,
|
||||
"resolution": predicted.resolution.value,
|
||||
"scope": predicted.scope.value,
|
||||
"risk_level": predicted.risk_level.value,
|
||||
"rationale": predicted.rationale,
|
||||
"source": predicted.source,
|
||||
"started_at_ms": started_at_ms,
|
||||
},
|
||||
)
|
||||
if self.emit_event and predicted.resolution != PermissionResolution.ALLOW:
|
||||
await self.emit_event(
|
||||
"permission_requested",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"arguments": arguments,
|
||||
"resolution": predicted.resolution.value,
|
||||
"scope": predicted.scope.value,
|
||||
"risk_level": predicted.risk_level.value,
|
||||
"rationale": predicted.rationale,
|
||||
"source": predicted.source,
|
||||
},
|
||||
)
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"tool_started",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"arguments": arguments,
|
||||
"predicted_permission": predicted.resolution.value,
|
||||
"started_at_ms": started_at_ms,
|
||||
},
|
||||
)
|
||||
|
||||
if batch_state is not None and self.converge_on_parallel_failure and batch_state["cascade_event"].is_set():
|
||||
return await self._build_converged_result(call, batch_state, batch_id=batch_id)
|
||||
|
||||
hook_context = RuntimeToolHookContext(
|
||||
phase="pre",
|
||||
tool_name=tool_name,
|
||||
call=call,
|
||||
task=task,
|
||||
tool=tool,
|
||||
arguments=dict(arguments),
|
||||
predicted_permission=predicted,
|
||||
)
|
||||
if self.hook_bus is not None:
|
||||
hook_context = await self.hook_bus.run_pre_hooks(hook_context)
|
||||
arguments = dict(hook_context.arguments)
|
||||
elif predicted.resolution == PermissionResolution.DENY:
|
||||
hook_context.result = self.permission_resolver.build_blocked_result(
|
||||
predicted,
|
||||
tool_name=tool_name,
|
||||
arguments=arguments,
|
||||
)
|
||||
hook_context.state["stop_batch_on_failure"] = True
|
||||
|
||||
if hook_context.result is not None:
|
||||
result = dict(hook_context.result)
|
||||
decision = self.permission_resolver.decision_from_result(tool_name, arguments, result)
|
||||
elif call.get("arguments_parse_error"):
|
||||
result = {
|
||||
"error": str(call.get("arguments_parse_error", "")),
|
||||
"invalid_arguments": True,
|
||||
"success": False,
|
||||
"raw_arguments": str(call.get("arguments_raw", "")),
|
||||
}
|
||||
decision = self.permission_resolver.decision_from_result(tool_name, arguments, result)
|
||||
else:
|
||||
last_progress: dict[str, str] = {"stream": "", "text": ""}
|
||||
last_progress_at = {"value": time.monotonic()}
|
||||
heartbeat_active = {"value": True}
|
||||
|
||||
async def _heartbeat() -> None:
|
||||
while heartbeat_active["value"]:
|
||||
await asyncio.sleep(_HEARTBEAT_INTERVAL_SECONDS)
|
||||
if not heartbeat_active["value"]:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now - last_progress_at["value"] < _HEARTBEAT_INTERVAL_SECONDS:
|
||||
continue
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"tool_progress",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"phase": "running",
|
||||
"message": f"{tool_name} still running",
|
||||
"heartbeat": True,
|
||||
"elapsed_ms": int((now - started_at_monotonic) * 1000),
|
||||
},
|
||||
)
|
||||
|
||||
async def _tool_progress(progress: Any, **progress_kw: Any) -> None:
|
||||
if isinstance(progress, dict):
|
||||
payload = dict(progress)
|
||||
text = str(payload.get("text", "") or payload.get("message", "") or "").strip()
|
||||
stream_name = str(payload.get("stream", "") or "").strip()
|
||||
else:
|
||||
text = str(progress or "").strip()
|
||||
stream_name = str(progress_kw.get("stream", "") or "").strip()
|
||||
payload = {
|
||||
"text": text,
|
||||
"stream": stream_name,
|
||||
}
|
||||
if not text:
|
||||
return
|
||||
if last_progress["text"] == text and last_progress["stream"] == stream_name:
|
||||
return
|
||||
last_progress["text"] = text
|
||||
last_progress["stream"] = stream_name
|
||||
last_progress_at["value"] = time.monotonic()
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"tool_progress",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"stream": stream_name,
|
||||
"elapsed_ms": int((last_progress_at["value"] - started_at_monotonic) * 1000),
|
||||
**payload,
|
||||
},
|
||||
)
|
||||
if on_progress:
|
||||
try:
|
||||
await on_progress(text, task_id=getattr(task, "id", None))
|
||||
except TypeError:
|
||||
await on_progress(text)
|
||||
|
||||
heartbeat_task = asyncio.create_task(_heartbeat())
|
||||
try:
|
||||
if tool is not None and tool.runtime_managed and self.runtime_tool_handler is not None:
|
||||
result = await self.runtime_tool_handler(tool_name, arguments)
|
||||
else:
|
||||
result = await self.registry.execute(
|
||||
tool_name,
|
||||
arguments,
|
||||
task=task,
|
||||
on_progress=_tool_progress,
|
||||
skip_approval=True,
|
||||
)
|
||||
result = await self._maybe_retry_with_escalated_sandbox(
|
||||
tool_name=tool_name,
|
||||
arguments=arguments,
|
||||
task=task,
|
||||
result=result,
|
||||
on_progress=_tool_progress,
|
||||
batch_id=batch_id,
|
||||
call=call,
|
||||
)
|
||||
finally:
|
||||
heartbeat_active["value"] = False
|
||||
heartbeat_task.cancel()
|
||||
await asyncio.gather(heartbeat_task, return_exceptions=True)
|
||||
hook_context.phase = "post"
|
||||
hook_context.arguments = dict(arguments)
|
||||
hook_context.result = dict(result)
|
||||
if self.hook_bus is not None:
|
||||
hook_context = await self.hook_bus.run_post_hooks(hook_context)
|
||||
result = dict(hook_context.result or result)
|
||||
if not bool(result.get("success", True)):
|
||||
hook_context.phase = "failure"
|
||||
hook_context.result = dict(result)
|
||||
hook_context = await self.hook_bus.run_failure_hooks(hook_context)
|
||||
result = dict(hook_context.result or result)
|
||||
decision = self.permission_resolver.decision_from_result(tool_name, arguments, result)
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"permission_resolved",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"arguments": arguments,
|
||||
"resolution": decision.resolution.value,
|
||||
"scope": decision.scope.value,
|
||||
"rationale": decision.rationale,
|
||||
},
|
||||
)
|
||||
await self.emit_event(
|
||||
"tool_completed",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"started_at_ms": started_at_ms,
|
||||
"completed_at_ms": _now_ms(),
|
||||
"elapsed_ms": int((time.monotonic() - started_at_monotonic) * 1000),
|
||||
"success": bool(result.get("success", True)),
|
||||
"result_summary": _result_summary(result),
|
||||
"result_preview": json.dumps(result, ensure_ascii=False, default=str)[:800],
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"tool_call": call,
|
||||
"result": result,
|
||||
"permission_decision": decision,
|
||||
"stop_batch_on_failure": bool(hook_context.state.get("stop_batch_on_failure")),
|
||||
"hook_metadata": {"batch_id": batch_id, **dict(hook_context.state.get("metadata", {}))},
|
||||
}
|
||||
|
||||
async def _maybe_retry_with_escalated_sandbox(
|
||||
self,
|
||||
*,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
task: Any,
|
||||
result: dict[str, Any],
|
||||
on_progress: Any,
|
||||
batch_id: str,
|
||||
call: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
guardian = getattr(self.permission_resolver.config, "guardian", None)
|
||||
if not guardian or not bool(getattr(guardian, "auto_retry_sandbox", False)):
|
||||
return result
|
||||
payload = result.get("result", {})
|
||||
if not isinstance(payload, dict):
|
||||
return result
|
||||
exit_code = payload.get("exit_code")
|
||||
if bool(result.get("success", True)) and exit_code in (None, 0):
|
||||
return result
|
||||
if tool_name not in {"shell_exec", "python_exec"}:
|
||||
return result
|
||||
sandbox_meta = dict(payload.get("sandbox", {}) or {})
|
||||
error_text = str(result.get("error", "") or payload.get("error", "") or "").lower()
|
||||
if not sandbox_meta and "sandbox" not in error_text:
|
||||
return result
|
||||
if task is None:
|
||||
return result
|
||||
execution_context = dict((getattr(task, "metadata", {}) or {}).get("_execution_context", {}) or {})
|
||||
sandbox_context = dict(execution_context.get("sandbox", {}) or {})
|
||||
current_mode = str(sandbox_context.get("mode", "") or "").strip().lower() or "off"
|
||||
next_mode = self._next_sandbox_mode(current_mode)
|
||||
if not next_mode:
|
||||
return result
|
||||
original_context = dict(execution_context)
|
||||
original_sandbox = dict(sandbox_context)
|
||||
retry_started_at_ms = _now_ms()
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"sandbox_retry_requested",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"from_mode": current_mode,
|
||||
"to_mode": next_mode,
|
||||
"started_at_ms": retry_started_at_ms,
|
||||
},
|
||||
)
|
||||
sandbox_context["mode"] = next_mode
|
||||
execution_context["sandbox"] = sandbox_context
|
||||
task.metadata = dict(getattr(task, "metadata", {}) or {})
|
||||
task.metadata["_execution_context"] = execution_context
|
||||
try:
|
||||
if self.registry.get(tool_name) is not None and getattr(self.registry.get(tool_name), "runtime_managed", False) and self.runtime_tool_handler is not None:
|
||||
retry_result = await self.runtime_tool_handler(tool_name, arguments)
|
||||
else:
|
||||
retry_result = await self.registry.execute(
|
||||
tool_name,
|
||||
arguments,
|
||||
task=task,
|
||||
on_progress=on_progress,
|
||||
skip_approval=True,
|
||||
)
|
||||
finally:
|
||||
original_context["sandbox"] = original_sandbox
|
||||
task.metadata["_execution_context"] = original_context
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"sandbox_retry_completed",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"from_mode": current_mode,
|
||||
"to_mode": next_mode,
|
||||
"started_at_ms": retry_started_at_ms,
|
||||
"completed_at_ms": _now_ms(),
|
||||
"success": bool(retry_result.get("success", True)),
|
||||
"result_summary": _result_summary(retry_result),
|
||||
},
|
||||
)
|
||||
return retry_result
|
||||
|
||||
@staticmethod
|
||||
def _next_sandbox_mode(current_mode: str) -> str:
|
||||
normalized = str(current_mode or "").strip().lower()
|
||||
if normalized == "workspace-write":
|
||||
return "elevated"
|
||||
if normalized == "elevated":
|
||||
return "off"
|
||||
return ""
|
||||
|
||||
async def _build_converged_result(
|
||||
self,
|
||||
call: dict[str, Any],
|
||||
batch_state: dict[str, Any],
|
||||
*,
|
||||
batch_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
tool_name = str(call.get("function", "") or "")
|
||||
result = {
|
||||
"error": (
|
||||
"Skipped because a concurrent sibling tool failed and the runtime converged the batch. "
|
||||
f"Source: {batch_state.get('failed_tool_name', '') or 'unknown'}"
|
||||
),
|
||||
"success": False,
|
||||
"converged": True,
|
||||
"converged_from_tool": batch_state.get("failed_tool_name", ""),
|
||||
"converged_from_call_id": batch_state.get("failed_call_id", ""),
|
||||
}
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"tool_skipped",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"reason": "parallel_batch_converged",
|
||||
"source_tool_name": batch_state.get("failed_tool_name", ""),
|
||||
"source_call_id": batch_state.get("failed_call_id", ""),
|
||||
},
|
||||
)
|
||||
decision = self.permission_resolver.decision_from_result(tool_name, dict(call.get("arguments", {}) or {}), result)
|
||||
return {
|
||||
"tool_call": call,
|
||||
"result": result,
|
||||
"permission_decision": decision,
|
||||
"stop_batch_on_failure": False,
|
||||
"hook_metadata": {"converged": True, "batch_id": batch_id},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _should_converge_batch(result: dict[str, Any]) -> bool:
|
||||
if bool(result.get("stop_batch_on_failure")):
|
||||
return True
|
||||
payload = result.get("result", {})
|
||||
if isinstance(payload, dict) and payload.get("prevent_continuation"):
|
||||
return True
|
||||
if isinstance(payload, dict):
|
||||
return not bool(payload.get("success", True))
|
||||
return False
|
||||
@@ -0,0 +1,766 @@
|
||||
"""Runtime-managed native subagents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Awaitable
|
||||
|
||||
from opc.core.config import OPCConfig, NativeSubagentProfileConfig
|
||||
from opc.core.models import OPCEvent, Task, TaskResult, TaskStatus
|
||||
from opc.layer2_organization.work_item_identity import projection_id_for_task, work_item_identity_payload_for_task
|
||||
from opc.layer3_agent.runtime_v2.worktree import cleanup_worktree, create_worktree
|
||||
|
||||
|
||||
ChildAgentFactory = Callable[..., Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubagentState:
|
||||
agent_id: str
|
||||
profile: str
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
model: str = ""
|
||||
mode: str = "default"
|
||||
isolation: str = "shared"
|
||||
max_iterations: int = 24
|
||||
status: str = "running"
|
||||
background: bool = False
|
||||
resident: bool = False
|
||||
fork_mode: bool = False
|
||||
created_at: float = field(default_factory=time.time)
|
||||
latest_result: str = ""
|
||||
pending_messages_count: int = 0
|
||||
last_notification_kind: str = ""
|
||||
worktree: dict[str, Any] | None = None
|
||||
fork_system_prompt: str = ""
|
||||
fork_context_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||
fork_allowed_tools: list[str] = field(default_factory=list)
|
||||
runtime_task: asyncio.Task[Any] | None = None
|
||||
inbox: asyncio.Queue[dict[str, Any]] = field(default_factory=asyncio.Queue)
|
||||
completion: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
update_event: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
task_result: TaskResult | None = None
|
||||
|
||||
|
||||
class SubagentManager:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
parent_task: Task | None,
|
||||
config: OPCConfig | None,
|
||||
child_agent_factory: ChildAgentFactory | None,
|
||||
event_bus: Any = None,
|
||||
store: Any = None,
|
||||
runtime_session_id: str = "",
|
||||
) -> None:
|
||||
self.parent_task = parent_task
|
||||
self.config = config or OPCConfig()
|
||||
self.child_agent_factory = child_agent_factory
|
||||
self.event_bus = event_bus
|
||||
self.store = store
|
||||
self.runtime_session_id = runtime_session_id
|
||||
self.states: dict[str, SubagentState] = {}
|
||||
self.agent_names: dict[str, str] = {}
|
||||
|
||||
async def spawn(
|
||||
self,
|
||||
*,
|
||||
profile: str,
|
||||
prompt: str,
|
||||
background: bool | None = None,
|
||||
isolation: str | None = None,
|
||||
description: str = "",
|
||||
name: str = "",
|
||||
model: str = "",
|
||||
mode: str = "default",
|
||||
fork_context_messages: list[dict[str, Any]] | None = None,
|
||||
fork_system_prompt: str = "",
|
||||
fork_allowed_tools: list[str] | None = None,
|
||||
fork_mode: bool = False,
|
||||
resident: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if self.child_agent_factory is None:
|
||||
return {"error": "Native subagent factory is not configured", "success": False}
|
||||
parent_depth = int(getattr(self.parent_task, "metadata", {}).get("_native_runtime_depth", 0) or 0)
|
||||
max_depth = int(self.config.system.native_runtime.subagent_max_depth or 3)
|
||||
if parent_depth >= max_depth:
|
||||
return {
|
||||
"error": f"Maximum native subagent depth ({max_depth}) reached",
|
||||
"success": False,
|
||||
}
|
||||
|
||||
profile_cfg = self._profile_config(profile)
|
||||
effective_mode = str(mode or "default").strip() or "default"
|
||||
default_isolation = profile_cfg.default_isolation
|
||||
if not default_isolation:
|
||||
default_isolation = "shared" if profile in {"explore", "plan"} else "worktree"
|
||||
effective_isolation = str(isolation or default_isolation or "shared").strip().lower() or "shared"
|
||||
if effective_mode == "plan":
|
||||
effective_isolation = "shared"
|
||||
if effective_isolation not in {"shared", "worktree"}:
|
||||
effective_isolation = str(profile_cfg.default_isolation or "shared").strip().lower() or "shared"
|
||||
effective_background = profile_cfg.background if background is None else bool(background)
|
||||
effective_resident = bool(resident) and bool(effective_background)
|
||||
effective_model = str(model or profile_cfg.model or "").strip()
|
||||
effective_description = str(description or prompt or "").strip()
|
||||
effective_name = str(name or "").strip()
|
||||
if effective_name and effective_name in self.agent_names:
|
||||
existing_state = self.states.get(self.agent_names[effective_name])
|
||||
if existing_state is not None and not existing_state.completion.is_set():
|
||||
return {"error": f"Subagent name `{effective_name}` is already in use", "success": False}
|
||||
|
||||
agent_id = f"na_{uuid.uuid4().hex[:10]}"
|
||||
worktree = None
|
||||
if effective_isolation == "worktree":
|
||||
base_path = str(getattr(self.parent_task, "metadata", {}).get("target_output_dir", "") or "").strip()
|
||||
worktree = await create_worktree(base_path or None, config=self.config)
|
||||
state = SubagentState(
|
||||
agent_id=agent_id,
|
||||
profile=profile,
|
||||
name=effective_name,
|
||||
description=effective_description,
|
||||
model=effective_model,
|
||||
mode=effective_mode,
|
||||
isolation=effective_isolation,
|
||||
max_iterations=max(1, int(profile_cfg.max_iterations or 24)),
|
||||
background=effective_background,
|
||||
resident=effective_resident,
|
||||
fork_mode=bool(fork_mode),
|
||||
worktree=worktree,
|
||||
fork_system_prompt=str(fork_system_prompt or ""),
|
||||
fork_context_messages=list(fork_context_messages or []),
|
||||
fork_allowed_tools=list(fork_allowed_tools or []),
|
||||
)
|
||||
self.states[agent_id] = state
|
||||
if effective_name:
|
||||
self.agent_names[effective_name] = agent_id
|
||||
self._ensure_comms_endpoint(state)
|
||||
await self._save_state(state, "running")
|
||||
if state.worktree and self.store and hasattr(self.store, "save_runtime_worktree_session"):
|
||||
await self.store.save_runtime_worktree_session(
|
||||
worktree_session_id=f"wt_{agent_id}",
|
||||
runtime_session_id=self.runtime_session_id,
|
||||
task_id=self.parent_task.id if self.parent_task else None,
|
||||
path=str(state.worktree.get("path", "") or ""),
|
||||
status="active",
|
||||
metadata=dict(state.worktree or {}),
|
||||
)
|
||||
await self._emit(
|
||||
"subagent_started",
|
||||
state,
|
||||
{
|
||||
"prompt": prompt,
|
||||
"description": effective_description,
|
||||
"name": effective_name,
|
||||
"isolation": effective_isolation,
|
||||
"mode": effective_mode,
|
||||
"model": effective_model,
|
||||
"fork_mode": state.fork_mode,
|
||||
"resident": state.resident,
|
||||
},
|
||||
)
|
||||
|
||||
async def _execute_turn(turn_prompt: str) -> None:
|
||||
try:
|
||||
state.status = "running"
|
||||
state.update_event.set()
|
||||
await self._save_state(state, state.status)
|
||||
child = self._build_child_task(state, turn_prompt)
|
||||
child.metadata["_permission_bridge_runtime_session_id"] = self.runtime_session_id
|
||||
setattr(child, "_runtime_permission_bridge", self._build_permission_bridge(state))
|
||||
child_agent = self._build_child_agent(
|
||||
profile=profile,
|
||||
allowed_tools=list(state.fork_allowed_tools) or self._resolve_allowed_tools(profile, mode=effective_mode),
|
||||
prompt_addendum=self._profile_prompt(profile, mode=effective_mode),
|
||||
state=state,
|
||||
)
|
||||
setattr(child, "_runtime_inbox_queue", state.inbox)
|
||||
result = await child_agent.execute(child)
|
||||
state.task_result = result
|
||||
state.latest_result = result.content
|
||||
terminal_status = result.status.value
|
||||
state.last_notification_kind = self._resident_notification_kind(result)
|
||||
state.status = "idle" if state.resident else terminal_status
|
||||
await self._save_state(state, state.status, {
|
||||
"turn_status": terminal_status,
|
||||
"notification_kind": state.last_notification_kind,
|
||||
})
|
||||
await self._emit(
|
||||
"subagent_completed",
|
||||
state,
|
||||
{
|
||||
"status": terminal_status,
|
||||
"resident": state.resident,
|
||||
"resident_status": state.status,
|
||||
"accepts_followups": bool(state.resident or not state.completion.is_set()),
|
||||
"pending_messages_count": state.pending_messages_count,
|
||||
"content_preview": result.content[:500],
|
||||
},
|
||||
)
|
||||
if state.resident:
|
||||
await self._emit_worker_notification(
|
||||
state,
|
||||
notification_kind=state.last_notification_kind or "idle",
|
||||
summary=result.content or f"{state.name or state.agent_id} is idle",
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
state.latest_result = str(exc)
|
||||
state.last_notification_kind = "error"
|
||||
state.task_result = TaskResult(status=TaskStatus.FAILED, content=str(exc))
|
||||
state.status = "idle" if state.resident else TaskStatus.FAILED.value
|
||||
await self._save_state(state, state.status, {"error": str(exc), "notification_kind": state.last_notification_kind})
|
||||
await self._emit(
|
||||
"subagent_completed",
|
||||
state,
|
||||
{
|
||||
"status": TaskStatus.FAILED.value,
|
||||
"resident": state.resident,
|
||||
"resident_status": state.status,
|
||||
"accepts_followups": bool(state.resident),
|
||||
"pending_messages_count": state.pending_messages_count,
|
||||
"content_preview": str(exc)[:500],
|
||||
},
|
||||
)
|
||||
if state.resident:
|
||||
await self._emit_worker_notification(
|
||||
state,
|
||||
notification_kind="error",
|
||||
summary=str(exc),
|
||||
)
|
||||
finally:
|
||||
state.update_event.set()
|
||||
|
||||
async def _runner() -> None:
|
||||
current_prompt = prompt
|
||||
try:
|
||||
while True:
|
||||
await _execute_turn(current_prompt)
|
||||
if not state.resident:
|
||||
return
|
||||
state.status = "idle"
|
||||
state.update_event.set()
|
||||
await self._save_state(state, state.status, {"notification_kind": state.last_notification_kind or "idle"})
|
||||
next_message = await state.inbox.get()
|
||||
state.pending_messages_count = max(0, state.pending_messages_count - 1)
|
||||
current_prompt = str(next_message.get("body", "") or "").strip()
|
||||
if not current_prompt:
|
||||
current_prompt = str(next_message.get("message", "") or "").strip()
|
||||
if not current_prompt:
|
||||
current_prompt = str(next_message)
|
||||
finally:
|
||||
state.completion.set()
|
||||
state.update_event.set()
|
||||
if state.worktree and self.store and hasattr(self.store, "save_runtime_worktree_session"):
|
||||
await self.store.save_runtime_worktree_session(
|
||||
worktree_session_id=f"wt_{agent_id}",
|
||||
runtime_session_id=self.runtime_session_id,
|
||||
task_id=self.parent_task.id if self.parent_task else None,
|
||||
path=str(state.worktree.get("path", "") or ""),
|
||||
status="closed",
|
||||
metadata=dict(state.worktree or {}),
|
||||
)
|
||||
await cleanup_worktree(state.worktree)
|
||||
|
||||
if effective_background:
|
||||
state.runtime_task = asyncio.create_task(_runner())
|
||||
return {
|
||||
"success": True,
|
||||
"agent_id": agent_id,
|
||||
"name": effective_name,
|
||||
"status": "running",
|
||||
"background": True,
|
||||
"resident": state.resident,
|
||||
"resident_status": state.status,
|
||||
"accepts_followups": bool(state.resident),
|
||||
"worktree_path": (state.worktree or {}).get("path", ""),
|
||||
}
|
||||
|
||||
await _runner()
|
||||
return self._result_payload(state)
|
||||
|
||||
async def wait(self, agent_id: str, timeout_seconds: int = 300) -> dict[str, Any]:
|
||||
resolved_id = self._resolve_agent_id(agent_id)
|
||||
state = self.states.get(resolved_id)
|
||||
if state is None:
|
||||
return {"error": f"Unknown subagent: {agent_id}", "success": False}
|
||||
if state.resident and state.status != "running":
|
||||
return self._result_payload(state)
|
||||
deadline = time.time() + max(1, int(timeout_seconds or 1))
|
||||
while True:
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
return self._result_payload(state)
|
||||
if state.completion.is_set():
|
||||
return self._result_payload(state)
|
||||
if state.resident and state.status != "running":
|
||||
return self._result_payload(state)
|
||||
try:
|
||||
await asyncio.wait_for(state.update_event.wait(), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
return self._result_payload(state)
|
||||
state.update_event.clear()
|
||||
return self._result_payload(state)
|
||||
|
||||
async def send(self, agent_id: str, message: str) -> dict[str, Any]:
|
||||
resolved_id = self._resolve_agent_id(agent_id)
|
||||
state = self.states.get(resolved_id)
|
||||
if state is None:
|
||||
return {"error": f"Unknown subagent: {agent_id}", "success": False}
|
||||
if state.completion.is_set():
|
||||
return {"error": f"Subagent {agent_id} has already completed", "success": False}
|
||||
rendered = str(message or "").strip()
|
||||
self._persist_follow_up_message(state, rendered)
|
||||
await state.inbox.put(
|
||||
{
|
||||
"body": rendered,
|
||||
"message_class": "chat",
|
||||
"actionable": True,
|
||||
"worker_id": state.agent_id,
|
||||
"origin_task_id": str(getattr(self.parent_task, "id", "") or "").strip(),
|
||||
"origin_session_id": str(getattr(self.parent_task, "session_id", "") or "").strip(),
|
||||
}
|
||||
)
|
||||
state.pending_messages_count += 1
|
||||
state.update_event.set()
|
||||
await self._save_state(state, state.status, {"queued_message": rendered[:500]})
|
||||
await self._emit(
|
||||
"subagent_updated",
|
||||
state,
|
||||
{
|
||||
"message": rendered[:500],
|
||||
"resident": state.resident,
|
||||
"resident_status": state.status,
|
||||
"pending_messages_count": state.pending_messages_count,
|
||||
},
|
||||
)
|
||||
return {"success": True, "agent_id": state.agent_id, "name": state.name, "status": state.status}
|
||||
|
||||
def list_agents(self) -> dict[str, Any]:
|
||||
return {
|
||||
"success": True,
|
||||
"agents": [self._result_payload(state) for state in self.states.values()],
|
||||
}
|
||||
|
||||
def _build_child_task(self, state: SubagentState, prompt: str) -> Task:
|
||||
parent = self.parent_task or Task()
|
||||
metadata = dict(parent.metadata or {})
|
||||
metadata.pop("_fork_allowed_tools", None)
|
||||
metadata["_native_runtime_depth"] = int(metadata.get("_native_runtime_depth", 0) or 0) + 1
|
||||
metadata["subagent_profile"] = state.profile
|
||||
metadata["_subagent_name"] = state.name
|
||||
metadata["_subagent_description"] = state.description
|
||||
metadata["_subagent_model"] = state.model
|
||||
metadata["_subagent_mode"] = state.mode
|
||||
metadata["_subagent_max_iterations"] = state.max_iterations
|
||||
metadata["_fork_mode"] = state.fork_mode
|
||||
if state.profile == "verify":
|
||||
metadata[self.config.system.native_runtime.verification_policy.skip_metadata_key] = True
|
||||
metadata["work_item_verification_required"] = False
|
||||
if state.worktree and state.worktree.get("path"):
|
||||
metadata["target_output_dir"] = state.worktree["path"]
|
||||
execution_context = dict((state.worktree or {}).get("execution_context", {}) or {})
|
||||
if execution_context:
|
||||
metadata["_execution_context"] = execution_context
|
||||
if state.fork_system_prompt:
|
||||
metadata["_runtime_system_prompt_override"] = state.fork_system_prompt
|
||||
if state.fork_context_messages:
|
||||
metadata["_fork_context_messages"] = list(state.fork_context_messages)
|
||||
if state.fork_allowed_tools:
|
||||
metadata["_fork_allowed_tools"] = list(state.fork_allowed_tools)
|
||||
metadata["_comms_endpoint_id"] = state.agent_id
|
||||
metadata["_comms_parent_endpoint_id"] = self._parent_endpoint_id()
|
||||
metadata["_subagent_resident"] = state.resident
|
||||
return Task(
|
||||
title=state.name or state.description or f"{state.profile} subagent",
|
||||
description=prompt,
|
||||
assigned_to=parent.assigned_to,
|
||||
project_id=parent.project_id,
|
||||
session_id=f"{parent.session_id or 'session'}:{state.agent_id}",
|
||||
parent_session_id=parent.session_id,
|
||||
parent_id=parent.id,
|
||||
tags=list(parent.tags),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def _resolve_allowed_tools(self, profile: str, mode: str = "default") -> list[str]:
|
||||
profiles = self.config.agents.native_subagents or {}
|
||||
profile_cfg: NativeSubagentProfileConfig = profiles.get(profile) or profiles.get("general") or NativeSubagentProfileConfig()
|
||||
if profile_cfg.allowed_tools:
|
||||
return self._apply_mode_tool_filter(list(profile_cfg.allowed_tools), mode)
|
||||
read_only = [
|
||||
"file_read",
|
||||
"file_search",
|
||||
"list_dir",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"todo_read",
|
||||
"todo_write",
|
||||
"request_user_input",
|
||||
"agent_spawn",
|
||||
"agent_wait",
|
||||
"agent_send",
|
||||
"agent_list",
|
||||
]
|
||||
implement = [
|
||||
"shell_exec",
|
||||
"file_read",
|
||||
"file_write",
|
||||
"file_edit",
|
||||
"file_search",
|
||||
"list_dir",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"python_exec",
|
||||
"todo_read",
|
||||
"todo_write",
|
||||
"request_user_input",
|
||||
"agent_spawn",
|
||||
"agent_wait",
|
||||
"agent_send",
|
||||
"agent_list",
|
||||
]
|
||||
verify = [
|
||||
"shell_exec",
|
||||
"file_read",
|
||||
"file_search",
|
||||
"list_dir",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"python_exec",
|
||||
"browser_navigate",
|
||||
"browser_snapshot",
|
||||
"browser_click",
|
||||
"browser_type",
|
||||
"browser_wait_for",
|
||||
"browser_scroll",
|
||||
"browser_take_screenshot",
|
||||
"browser_close",
|
||||
"todo_read",
|
||||
"todo_write",
|
||||
"request_user_input",
|
||||
"agent_spawn",
|
||||
"agent_wait",
|
||||
"agent_send",
|
||||
"agent_list",
|
||||
]
|
||||
mapping = {
|
||||
"general": implement,
|
||||
"explore": read_only,
|
||||
"plan": read_only,
|
||||
"implement": implement,
|
||||
"verify": verify,
|
||||
}
|
||||
return self._apply_mode_tool_filter(mapping.get(profile, implement), mode)
|
||||
|
||||
def _profile_prompt(self, profile: str, mode: str = "default") -> str:
|
||||
prompts = {
|
||||
"general": "Complete the task directly and report only the essential outcome.",
|
||||
"explore": "Read-only exploration only. Do not modify files or system state.",
|
||||
"plan": "Read-only planning only. Produce a concise implementation plan with critical files.",
|
||||
"implement": "Implement directly in the assigned workspace, then verify your changes with commands.",
|
||||
"verify": (
|
||||
"Try to break the implementation. Prefer executable checks over code reading. "
|
||||
"For every check you actually ran, emit `Check:`, `Command:`, `Observed Output:`, and `Result:` lines. "
|
||||
"End with exactly one line `VERDICT: PASS`, `VERDICT: FAIL`, or `VERDICT: PARTIAL`. "
|
||||
"You may start the final summary with `VERIFIED:` if the work is acceptable, or `ISSUES:` if blocking problems remain."
|
||||
),
|
||||
}
|
||||
base = prompts.get(profile, prompts["general"])
|
||||
normalized_mode = str(mode or "default").strip().lower()
|
||||
if normalized_mode == "plan":
|
||||
return base + " Operate in plan mode: do not make filesystem or shell changes."
|
||||
if normalized_mode in {"accept_edits", "bypass_permissions", "dont_ask", "acceptedits", "bypasspermissions", "dontask"}:
|
||||
return base + f" Runtime spawn mode hint: {mode}."
|
||||
return base
|
||||
|
||||
def _profile_config(self, profile: str) -> NativeSubagentProfileConfig:
|
||||
profiles = self.config.agents.native_subagents or {}
|
||||
return profiles.get(profile) or profiles.get("general") or NativeSubagentProfileConfig()
|
||||
|
||||
def _apply_mode_tool_filter(self, tools: list[str], mode: str) -> list[str]:
|
||||
if str(mode or "default").strip().lower() != "plan":
|
||||
return list(tools)
|
||||
plan_safe = {
|
||||
"file_read",
|
||||
"file_search",
|
||||
"list_dir",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"todo_read",
|
||||
"todo_write",
|
||||
"request_user_input",
|
||||
"agent_spawn",
|
||||
"agent_wait",
|
||||
"agent_send",
|
||||
"agent_list",
|
||||
}
|
||||
return [tool for tool in tools if tool in plan_safe]
|
||||
|
||||
def _build_child_agent(
|
||||
self,
|
||||
*,
|
||||
profile: str,
|
||||
allowed_tools: list[str],
|
||||
prompt_addendum: str,
|
||||
state: SubagentState,
|
||||
) -> Any:
|
||||
overrides = {
|
||||
"name": state.name,
|
||||
"description": state.description,
|
||||
"model": state.model,
|
||||
"mode": state.mode,
|
||||
"max_iterations": state.max_iterations,
|
||||
}
|
||||
signature = inspect.signature(self.child_agent_factory)
|
||||
accepts_overrides = len(signature.parameters) >= 4 or any(
|
||||
parameter.kind == inspect.Parameter.VAR_POSITIONAL
|
||||
for parameter in signature.parameters.values()
|
||||
)
|
||||
if accepts_overrides:
|
||||
return self.child_agent_factory(profile, allowed_tools, prompt_addendum, overrides)
|
||||
return self.child_agent_factory(profile, allowed_tools, prompt_addendum)
|
||||
|
||||
def _build_permission_bridge(self, state: SubagentState) -> Callable[..., Awaitable[tuple[bool, Any]]]:
|
||||
async def _bridge(
|
||||
*,
|
||||
tool: Any,
|
||||
arguments: dict[str, Any],
|
||||
approval_engine: Any,
|
||||
on_progress: Any = None,
|
||||
) -> tuple[bool, Any]:
|
||||
parent_task = self.parent_task or Task(project_id="default")
|
||||
metadata = {
|
||||
"category": getattr(tool, "category", "general"),
|
||||
"requires_confirmation": getattr(tool, "requires_confirmation", False),
|
||||
"description": getattr(tool, "description", ""),
|
||||
"subagent_id": state.agent_id,
|
||||
"subagent_profile": state.profile,
|
||||
"subagent_name": state.name,
|
||||
"subagent_mode": state.mode,
|
||||
"bridged_runtime_session_id": self.runtime_session_id,
|
||||
}
|
||||
return await approval_engine.authorize_tool_call(
|
||||
task=parent_task,
|
||||
tool_name=getattr(tool, "name", ""),
|
||||
arguments=arguments,
|
||||
metadata=metadata,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
return _bridge
|
||||
|
||||
def _resolve_agent_id(self, agent_id: str) -> str:
|
||||
raw = str(agent_id or "").strip()
|
||||
if raw in self.states:
|
||||
return raw
|
||||
return self.agent_names.get(raw, raw)
|
||||
|
||||
def _parent_endpoint_id(self) -> str:
|
||||
if self.parent_task is None:
|
||||
return "runtime-parent"
|
||||
session_id = str(getattr(self.parent_task, "session_id", "") or "").strip()
|
||||
task_id = str(getattr(self.parent_task, "id", "") or "").strip()
|
||||
return f"task::{session_id or task_id or 'runtime-parent'}"
|
||||
|
||||
def _comms_layout(self):
|
||||
if self.parent_task is None:
|
||||
return None
|
||||
workspace_root = (
|
||||
str(getattr(self.parent_task, "metadata", {}).get("comms_workspace_root", "") or "").strip()
|
||||
or str(getattr(self.parent_task, "metadata", {}).get("workspace_root", "") or "").strip()
|
||||
or str(getattr(self.parent_task, "metadata", {}).get("target_output_dir", "") or "").strip()
|
||||
)
|
||||
if not workspace_root:
|
||||
return None
|
||||
try:
|
||||
from opc.layer2_organization import comms as _comms
|
||||
|
||||
return _comms.resolve_layout(
|
||||
workspace_root,
|
||||
str(getattr(self.parent_task, "project_id", "") or "default").strip() or "default",
|
||||
str(getattr(self.parent_task, "parent_session_id", "") or getattr(self.parent_task, "session_id", "") or "default").strip() or "default",
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _ensure_comms_endpoint(self, state: SubagentState) -> None:
|
||||
layout = self._comms_layout()
|
||||
if layout is None:
|
||||
return
|
||||
try:
|
||||
from opc.layer2_organization import comms as _comms
|
||||
|
||||
_comms.ensure_layout(layout, [self._parent_endpoint_id(), state.agent_id])
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def _persist_follow_up_message(self, state: SubagentState, message: str) -> None:
|
||||
if not message:
|
||||
return
|
||||
layout = self._comms_layout()
|
||||
if layout is None:
|
||||
return
|
||||
try:
|
||||
from opc.layer2_organization import comms as _comms
|
||||
|
||||
_comms.send_message(
|
||||
layout,
|
||||
from_role=self._parent_endpoint_id(),
|
||||
to_role=state.agent_id,
|
||||
subject=f"Follow-up for {state.name or state.agent_id}",
|
||||
body=message,
|
||||
priority="normal",
|
||||
extra_frontmatter={
|
||||
"transport_kind": "system",
|
||||
"semantic_type": "work_update",
|
||||
"message_class": "chat",
|
||||
"actionable": True,
|
||||
"worker_id": state.agent_id,
|
||||
"origin_task_id": str(getattr(self.parent_task, "id", "") or "").strip(),
|
||||
"origin_session_id": str(getattr(self.parent_task, "session_id", "") or "").strip(),
|
||||
"comms_state": "open",
|
||||
"from_endpoint_type": "native_subagent",
|
||||
"to_endpoint_type": "native_subagent",
|
||||
"refs": {
|
||||
"task_id": str(getattr(self.parent_task, "id", "") or "").strip(),
|
||||
"runtime_session_id": self.runtime_session_id,
|
||||
},
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
async def _emit(self, event_type: str, state: SubagentState, payload: dict[str, Any]) -> None:
|
||||
if not self.event_bus:
|
||||
return
|
||||
await self.event_bus.publish(OPCEvent(
|
||||
event_type="runtime_event",
|
||||
payload={
|
||||
"type": event_type,
|
||||
"agent_id": state.agent_id,
|
||||
"profile": state.profile,
|
||||
"task_id": str(getattr(self.parent_task, "id", "") or "").strip(),
|
||||
"session_id": str(getattr(self.parent_task, "session_id", "") or "").strip(),
|
||||
"resident": state.resident,
|
||||
"resident_status": state.status,
|
||||
"pending_messages_count": state.pending_messages_count,
|
||||
**payload,
|
||||
},
|
||||
))
|
||||
|
||||
async def _save_state(self, state: SubagentState, status: str, metadata: dict[str, Any] | None = None) -> None:
|
||||
if not self.store or not hasattr(self.store, "save_runtime_subagent_run"):
|
||||
return
|
||||
merged_metadata = {
|
||||
"name": state.name,
|
||||
"description": state.description,
|
||||
"model": state.model,
|
||||
"mode": state.mode,
|
||||
"isolation": state.isolation,
|
||||
"max_iterations": state.max_iterations,
|
||||
"fork_mode": state.fork_mode,
|
||||
"fork_context_messages": len(state.fork_context_messages),
|
||||
"resident": state.resident,
|
||||
"resident_status": state.status,
|
||||
"accepts_followups": bool(state.resident and not state.completion.is_set()),
|
||||
"pending_messages_count": state.pending_messages_count,
|
||||
"last_notification_kind": state.last_notification_kind,
|
||||
}
|
||||
merged_metadata.update(metadata or {})
|
||||
await self.store.save_runtime_subagent_run(
|
||||
subagent_run_id=state.agent_id,
|
||||
runtime_session_id=self.runtime_session_id,
|
||||
task_id=self.parent_task.id if self.parent_task else None,
|
||||
agent_id=state.agent_id,
|
||||
profile=state.profile,
|
||||
status=status,
|
||||
worktree_path=str((state.worktree or {}).get("path", "") or ""),
|
||||
metadata=merged_metadata,
|
||||
)
|
||||
|
||||
async def _emit_worker_notification(
|
||||
self,
|
||||
state: SubagentState,
|
||||
*,
|
||||
notification_kind: str,
|
||||
summary: str,
|
||||
actionable: bool = False,
|
||||
) -> None:
|
||||
payload = {
|
||||
"worker_id": state.agent_id,
|
||||
"worker_type": "native_subagent",
|
||||
"notification_kind": str(notification_kind or "idle").strip() or "idle",
|
||||
"summary": str(summary or "").strip(),
|
||||
"task_id": str(getattr(self.parent_task, "id", "") or "").strip(),
|
||||
"session_id": str(getattr(self.parent_task, "session_id", "") or "").strip(),
|
||||
**work_item_identity_payload_for_task(self.parent_task),
|
||||
"projection_id": projection_id_for_task(self.parent_task) if self.parent_task is not None else "",
|
||||
"details_ref": state.agent_id,
|
||||
"actionable": bool(actionable),
|
||||
"resident_status": state.status,
|
||||
"pending_messages_count": state.pending_messages_count,
|
||||
"name": state.name,
|
||||
}
|
||||
await self._emit("worker_notification", state, payload)
|
||||
|
||||
@staticmethod
|
||||
def _resident_notification_kind(result: TaskResult) -> str:
|
||||
if result.status in {TaskStatus.AWAITING_HUMAN, TaskStatus.AWAITING_REVIEW}:
|
||||
return "permission_needed"
|
||||
if result.status == TaskStatus.AWAITING_PEER:
|
||||
return "blocked"
|
||||
if result.status == TaskStatus.FAILED:
|
||||
return "error"
|
||||
if result.status == TaskStatus.DONE:
|
||||
return "task_complete"
|
||||
return "idle"
|
||||
|
||||
def _result_payload(self, state: SubagentState) -> dict[str, Any]:
|
||||
payload = {
|
||||
"success": state.task_result is not None and state.task_result.status == TaskStatus.DONE,
|
||||
"agent_id": state.agent_id,
|
||||
"profile": state.profile,
|
||||
"name": state.name,
|
||||
"description": state.description,
|
||||
"model": state.model,
|
||||
"mode": state.mode,
|
||||
"isolation": state.isolation,
|
||||
"max_iterations": state.max_iterations,
|
||||
"status": state.status,
|
||||
"background": state.background,
|
||||
"fork_mode": state.fork_mode,
|
||||
"resident": state.resident,
|
||||
"resident_status": state.status,
|
||||
"accepts_followups": bool(state.resident and not state.completion.is_set()),
|
||||
"pending_messages_count": state.pending_messages_count,
|
||||
"last_notification_kind": state.last_notification_kind,
|
||||
"result": state.latest_result,
|
||||
"worktree_path": (state.worktree or {}).get("path", ""),
|
||||
"venv_path": (state.worktree or {}).get("venv_path", ""),
|
||||
"python_executable": (state.worktree or {}).get("python_executable", ""),
|
||||
}
|
||||
if state.task_result and state.task_result.status in {TaskStatus.AWAITING_HUMAN, TaskStatus.AWAITING_REVIEW}:
|
||||
artifacts = dict(state.task_result.artifacts or {})
|
||||
payload.update(
|
||||
{
|
||||
"requires_user_input": True,
|
||||
"reason": state.task_result.content,
|
||||
"approval": dict(artifacts.get("approval", {}) or {}),
|
||||
"permission_requests": list(artifacts.get("permission_requests", []) or []),
|
||||
}
|
||||
)
|
||||
if state.task_result and state.task_result.status == TaskStatus.AWAITING_PEER:
|
||||
artifacts = dict(state.task_result.artifacts or {})
|
||||
payload.update(
|
||||
{
|
||||
"requires_peer_wait": True,
|
||||
"reason": state.task_result.content,
|
||||
"permission_requests": list(artifacts.get("permission_requests", []) or []),
|
||||
}
|
||||
)
|
||||
return payload
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Runtime-managed tool hook bus for Native Runtime V2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeToolHookContext:
|
||||
phase: str
|
||||
tool_name: str
|
||||
call: dict[str, Any]
|
||||
task: Any = None
|
||||
tool: Any = None
|
||||
arguments: dict[str, Any] = field(default_factory=dict)
|
||||
predicted_permission: Any = None
|
||||
result: dict[str, Any] | None = None
|
||||
state: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
RuntimeToolHook = Callable[[RuntimeToolHookContext], Awaitable[Optional[dict[str, Any]]]]
|
||||
RuntimeHookEmitter = Callable[[str, dict[str, Any]], Awaitable[None]]
|
||||
|
||||
|
||||
class RuntimeToolHookBus:
|
||||
"""Composable pre/post/failure hook bus for runtime-managed tool execution."""
|
||||
|
||||
def __init__(self, *, emit_event: RuntimeHookEmitter | None = None) -> None:
|
||||
self.emit_event = emit_event
|
||||
self._pre_hooks: list[tuple[str, RuntimeToolHook]] = []
|
||||
self._post_hooks: list[tuple[str, RuntimeToolHook]] = []
|
||||
self._failure_hooks: list[tuple[str, RuntimeToolHook]] = []
|
||||
|
||||
def register_pre_hook(self, name: str, hook: RuntimeToolHook) -> None:
|
||||
self._pre_hooks.append((name, hook))
|
||||
|
||||
def register_post_hook(self, name: str, hook: RuntimeToolHook) -> None:
|
||||
self._post_hooks.append((name, hook))
|
||||
|
||||
def register_failure_hook(self, name: str, hook: RuntimeToolHook) -> None:
|
||||
self._failure_hooks.append((name, hook))
|
||||
|
||||
async def run_pre_hooks(self, context: RuntimeToolHookContext) -> RuntimeToolHookContext:
|
||||
return await self._run_hooks(self._pre_hooks, context)
|
||||
|
||||
async def run_post_hooks(self, context: RuntimeToolHookContext) -> RuntimeToolHookContext:
|
||||
return await self._run_hooks(self._post_hooks, context)
|
||||
|
||||
async def run_failure_hooks(self, context: RuntimeToolHookContext) -> RuntimeToolHookContext:
|
||||
return await self._run_hooks(self._failure_hooks, context)
|
||||
|
||||
async def _run_hooks(
|
||||
self,
|
||||
hooks: list[tuple[str, RuntimeToolHook]],
|
||||
context: RuntimeToolHookContext,
|
||||
) -> RuntimeToolHookContext:
|
||||
for hook_name, hook in hooks:
|
||||
patch = await hook(context) or {}
|
||||
self._apply_patch(context, patch)
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"tool_hook",
|
||||
{
|
||||
"phase": context.phase,
|
||||
"tool_name": context.tool_name,
|
||||
"tool_call_id": context.call.get("id", ""),
|
||||
"hook_name": hook_name,
|
||||
"stopped": bool(context.state.get("stop_execution")),
|
||||
"result_overridden": context.result is not None,
|
||||
},
|
||||
)
|
||||
if context.state.get("stop_execution"):
|
||||
break
|
||||
return context
|
||||
|
||||
@staticmethod
|
||||
def _apply_patch(context: RuntimeToolHookContext, patch: dict[str, Any]) -> None:
|
||||
if not patch:
|
||||
return
|
||||
if isinstance(patch.get("arguments"), dict):
|
||||
context.arguments = dict(patch["arguments"])
|
||||
if isinstance(patch.get("result"), dict):
|
||||
context.result = dict(patch["result"])
|
||||
if isinstance(patch.get("metadata"), dict):
|
||||
context.state.setdefault("metadata", {}).update(dict(patch["metadata"]))
|
||||
if isinstance(patch.get("approval"), dict):
|
||||
context.state.setdefault("approval", {}).update(dict(patch["approval"]))
|
||||
if "stop_execution" in patch:
|
||||
context.state["stop_execution"] = bool(patch["stop_execution"])
|
||||
if "stop_batch_on_failure" in patch:
|
||||
context.state["stop_batch_on_failure"] = bool(patch["stop_batch_on_failure"])
|
||||
if "prevent_continuation" in patch:
|
||||
context.state["prevent_continuation"] = bool(patch["prevent_continuation"])
|
||||
if patch.get("stop_reason"):
|
||||
context.state["stop_reason"] = str(patch["stop_reason"])
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Tool planning helpers for Native Runtime V2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from opc.layer4_tools.registry import ToolDefinition, ToolRegistry
|
||||
|
||||
|
||||
_READ_ONLY_TOOL_NAMES = {
|
||||
"file_read",
|
||||
"file_search",
|
||||
"list_dir",
|
||||
"grep",
|
||||
"glob",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"todo_read",
|
||||
"agent_list",
|
||||
"agent_wait",
|
||||
}
|
||||
|
||||
_NON_CONCURRENT_TOOL_NAMES = {
|
||||
"shell_exec",
|
||||
"file_write",
|
||||
"file_edit",
|
||||
"apply_patch",
|
||||
"python_exec",
|
||||
"git_commit",
|
||||
"agent_spawn",
|
||||
"agent_wait",
|
||||
"agent_send",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolBatch:
|
||||
concurrency_safe: bool
|
||||
calls: list[dict[str, Any]]
|
||||
|
||||
|
||||
class ToolPlanner:
|
||||
"""Determine tool execution ordering and concurrency."""
|
||||
|
||||
def __init__(self, registry: ToolRegistry, max_parallel_read_tools: int = 6) -> None:
|
||||
self.registry = registry
|
||||
self.max_parallel_read_tools = max(1, int(max_parallel_read_tools or 1))
|
||||
|
||||
def is_read_only(self, tool: ToolDefinition | None) -> bool:
|
||||
if tool is None:
|
||||
return False
|
||||
if tool.read_only is not None:
|
||||
return bool(tool.read_only)
|
||||
if tool.name in _READ_ONLY_TOOL_NAMES:
|
||||
return True
|
||||
return tool.category in {"search", "read"} or tool.name.endswith("_read")
|
||||
|
||||
def is_concurrency_safe(self, tool: ToolDefinition | None) -> bool:
|
||||
if tool is None:
|
||||
return False
|
||||
if tool.concurrency_safe is not None:
|
||||
return bool(tool.concurrency_safe)
|
||||
if tool.name in _NON_CONCURRENT_TOOL_NAMES:
|
||||
return False
|
||||
return self.is_read_only(tool)
|
||||
|
||||
def partition(self, tool_calls: list[dict[str, Any]]) -> list[ToolBatch]:
|
||||
batches: list[ToolBatch] = []
|
||||
for call in tool_calls:
|
||||
tool = self.registry.get(str(call.get("function", "") or ""))
|
||||
concurrency_safe = self.is_concurrency_safe(tool)
|
||||
if concurrency_safe and batches and batches[-1].concurrency_safe:
|
||||
batches[-1].calls.append(call)
|
||||
else:
|
||||
batches.append(ToolBatch(concurrency_safe=concurrency_safe, calls=[call]))
|
||||
return batches
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Local worktree helpers for runtime-managed subagents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from opc.core.config import OPCConfig
|
||||
from opc.layer4_tools.execution_context import build_task_execution_context, venv_python_path
|
||||
|
||||
|
||||
async def create_worktree(
|
||||
base_path: str | None,
|
||||
*,
|
||||
config: OPCConfig | None = None,
|
||||
on_progress: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
source = Path(base_path or os.getcwd()).resolve()
|
||||
root = await _find_git_root(source)
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="opc-native-v2-"))
|
||||
|
||||
if root is not None:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
"-C",
|
||||
str(root),
|
||||
"worktree",
|
||||
"add",
|
||||
"--detach",
|
||||
str(temp_dir),
|
||||
"HEAD",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode == 0:
|
||||
info = {
|
||||
"path": str(temp_dir),
|
||||
"git_root": str(root),
|
||||
"mode": "git_worktree",
|
||||
"stdout": stdout.decode("utf-8", errors="replace"),
|
||||
}
|
||||
return await _prepare_execution_environment(info, config=config, on_progress=on_progress)
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="opc-native-v2-copy-"))
|
||||
|
||||
shutil.copytree(
|
||||
source,
|
||||
temp_dir,
|
||||
dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns(".git", ".venv", "__pycache__", ".pytest_cache"),
|
||||
)
|
||||
info = {
|
||||
"path": str(temp_dir),
|
||||
"git_root": str(root) if root else "",
|
||||
"mode": "copy",
|
||||
}
|
||||
return await _prepare_execution_environment(info, config=config, on_progress=on_progress)
|
||||
|
||||
|
||||
async def cleanup_worktree(info: dict[str, Any] | None) -> None:
|
||||
if not info:
|
||||
return
|
||||
path = Path(str(info.get("path", "") or "")).resolve()
|
||||
mode = str(info.get("mode", "") or "")
|
||||
git_root = str(info.get("git_root", "") or "")
|
||||
if not path.exists():
|
||||
return
|
||||
if mode == "git_worktree" and git_root:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
"-C",
|
||||
git_root,
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
str(path),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await proc.communicate()
|
||||
return
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
|
||||
async def _find_git_root(path: Path) -> Path | None:
|
||||
current = path
|
||||
if current.is_file():
|
||||
current = current.parent
|
||||
for candidate in [current, *current.parents]:
|
||||
if (candidate / ".git").exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
async def _prepare_execution_environment(
|
||||
info: dict[str, Any],
|
||||
*,
|
||||
config: OPCConfig | None = None,
|
||||
on_progress: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
workspace = Path(str(info.get("path", "") or "")).resolve()
|
||||
info["execution_context"] = build_task_execution_context(workspace_root=workspace, config=config)
|
||||
if config is None:
|
||||
return info
|
||||
venv_cfg = config.system.native_runtime.execution_environment.worktree_venv
|
||||
if not venv_cfg.enabled:
|
||||
return info
|
||||
|
||||
provider = _resolve_venv_provider(venv_cfg.provider)
|
||||
venv_path = workspace / str(venv_cfg.venv_dir or ".opc-venv")
|
||||
python_path = venv_python_path(venv_path)
|
||||
try:
|
||||
if not python_path.exists():
|
||||
create_args = _venv_create_args(provider, venv_path, system_site_packages=venv_cfg.system_site_packages)
|
||||
await _run_process(create_args, cwd=workspace, on_progress=on_progress)
|
||||
sync_commands = _build_sync_commands(
|
||||
workspace=workspace,
|
||||
python_path=python_path,
|
||||
provider=provider,
|
||||
editable_project=venv_cfg.editable_project,
|
||||
requirements_files=_detect_requirements_files(
|
||||
workspace=workspace,
|
||||
configured=venv_cfg.requirements_files,
|
||||
auto_detect=venv_cfg.auto_detect_requirements,
|
||||
),
|
||||
)
|
||||
for command in sync_commands:
|
||||
await _run_process(command, cwd=workspace, on_progress=on_progress)
|
||||
info.update(
|
||||
{
|
||||
"venv_path": str(venv_path),
|
||||
"python_executable": str(python_path),
|
||||
"venv_provider": provider,
|
||||
"environment_prepared": True,
|
||||
"environment_sync_commands": len(sync_commands),
|
||||
}
|
||||
)
|
||||
info["execution_context"] = build_task_execution_context(
|
||||
workspace_root=workspace,
|
||||
config=config,
|
||||
venv_path=venv_path,
|
||||
python_executable=python_path,
|
||||
venv_provider=provider,
|
||||
)
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
info.update(
|
||||
{
|
||||
"venv_path": str(venv_path),
|
||||
"python_executable": str(python_path),
|
||||
"venv_provider": provider,
|
||||
"environment_prepared": False,
|
||||
"environment_error": error,
|
||||
}
|
||||
)
|
||||
info["execution_context"] = build_task_execution_context(
|
||||
workspace_root=workspace,
|
||||
config=config,
|
||||
venv_path=venv_path,
|
||||
python_executable=python_path,
|
||||
venv_provider=provider,
|
||||
preparation_error=error,
|
||||
)
|
||||
if venv_cfg.fail_if_prepare_fails:
|
||||
raise
|
||||
return info
|
||||
|
||||
|
||||
def _resolve_venv_provider(provider: str) -> str:
|
||||
normalized = str(provider or "auto").strip().lower() or "auto"
|
||||
if normalized == "uv":
|
||||
return "uv"
|
||||
if normalized == "venv":
|
||||
return "venv"
|
||||
return "uv" if shutil.which("uv") else "venv"
|
||||
|
||||
|
||||
def _venv_create_args(provider: str, venv_path: Path, *, system_site_packages: bool) -> list[str]:
|
||||
if provider == "uv":
|
||||
return ["uv", "venv", str(venv_path), "--python", sys.executable]
|
||||
args = [sys.executable, "-m", "venv", str(venv_path)]
|
||||
if system_site_packages:
|
||||
args.append("--system-site-packages")
|
||||
return args
|
||||
|
||||
|
||||
def _detect_requirements_files(
|
||||
*,
|
||||
workspace: Path,
|
||||
configured: list[str],
|
||||
auto_detect: bool,
|
||||
) -> list[Path]:
|
||||
candidates: list[Path] = []
|
||||
for entry in configured:
|
||||
text = str(entry or "").strip()
|
||||
if text:
|
||||
candidates.append((workspace / text).resolve())
|
||||
if auto_detect:
|
||||
for name in ("requirements.txt", "requirements-dev.txt", "requirements-test.txt", "requirements-ci.txt"):
|
||||
candidate = (workspace / name).resolve()
|
||||
if candidate.exists():
|
||||
candidates.append(candidate)
|
||||
seen: set[str] = set()
|
||||
resolved: list[Path] = []
|
||||
for item in candidates:
|
||||
key = str(item)
|
||||
if key in seen or not item.exists():
|
||||
continue
|
||||
seen.add(key)
|
||||
resolved.append(item)
|
||||
return resolved
|
||||
|
||||
|
||||
def _build_sync_commands(
|
||||
*,
|
||||
workspace: Path,
|
||||
python_path: Path,
|
||||
provider: str,
|
||||
editable_project: bool,
|
||||
requirements_files: list[Path],
|
||||
) -> list[list[str]]:
|
||||
commands: list[list[str]] = []
|
||||
if editable_project and (workspace / "pyproject.toml").exists():
|
||||
if provider == "uv" and shutil.which("uv"):
|
||||
commands.append(["uv", "pip", "install", "--python", str(python_path), "-e", str(workspace)])
|
||||
else:
|
||||
commands.append([str(python_path), "-m", "pip", "install", "-e", str(workspace)])
|
||||
for item in requirements_files:
|
||||
if provider == "uv" and shutil.which("uv"):
|
||||
commands.append(["uv", "pip", "install", "--python", str(python_path), "-r", str(item)])
|
||||
else:
|
||||
commands.append([str(python_path), "-m", "pip", "install", "-r", str(item)])
|
||||
return commands
|
||||
|
||||
|
||||
async def _run_process(args: list[str], *, cwd: Path, on_progress: Any = None) -> None:
|
||||
if on_progress:
|
||||
try:
|
||||
await on_progress(f"[worktree-env] {' '.join(args[:4])}")
|
||||
except TypeError:
|
||||
await on_progress(f"[worktree-env] {' '.join(args[:4])}")
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
cwd=str(cwd),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode == 0:
|
||||
return
|
||||
stdout_text = stdout.decode("utf-8", errors="replace").strip()
|
||||
stderr_text = stderr.decode("utf-8", errors="replace").strip()
|
||||
detail = stderr_text or stdout_text or f"exit code {proc.returncode}"
|
||||
raise RuntimeError(f"{' '.join(args[:4])} failed: {detail}")
|
||||
Reference in New Issue
Block a user