Files
zk-data-agent/src/plugin_runtime.py
T
Abdelrahman Abdallah 4de3da10e4 add test guide cases
2026-04-03 16:50:19 +02:00

655 lines
24 KiB
Python

from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class PluginToolAlias:
name: str
base_tool: str
description: str | None = None
@dataclass(frozen=True)
class PluginToolHook:
tool_name: str
before_tool: str | None = None
after_result: str | None = None
block_message: str | None = None
@dataclass(frozen=True)
class PluginVirtualTool:
name: str
description: str
response_template: str
parameters: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class PluginManifest:
name: str
path: str
version: str | None = None
description: str | None = None
tool_names: tuple[str, ...] = ()
hook_names: tuple[str, ...] = ()
tool_aliases: tuple[PluginToolAlias, ...] = ()
virtual_tools: tuple[PluginVirtualTool, ...] = ()
tool_hooks: tuple[PluginToolHook, ...] = ()
blocked_tools: tuple[str, ...] = ()
before_prompt: str | None = None
after_turn: str | None = None
on_resume: str | None = None
before_persist: str | None = None
before_delegate: str | None = None
after_delegate: str | None = None
@dataclass
class PluginRuntime:
manifests: tuple[PluginManifest, ...] = field(default_factory=tuple)
session_state: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_workspace(
cls,
cwd: Path,
additional_working_directories: tuple[str, ...] = (),
) -> 'PluginRuntime':
manifests: list[PluginManifest] = []
for path in _discover_plugin_manifest_paths(cwd, additional_working_directories):
manifest = _load_manifest(path)
if manifest is not None:
manifests.append(manifest)
return cls(manifests=tuple(manifests))
def instruction_blocks(self) -> tuple[str, ...]:
blocks: list[str] = []
for manifest in self.manifests:
lines = [
f'Plugin: {manifest.name}',
]
if manifest.description:
lines.append(f'Description: {manifest.description}')
if manifest.tool_names:
lines.append(f'Tools: {", ".join(manifest.tool_names)}')
if manifest.hook_names:
lines.append(f'Hooks: {", ".join(manifest.hook_names)}')
if manifest.tool_aliases:
lines.append(
'Tool aliases: '
+ ', '.join(alias.name for alias in manifest.tool_aliases)
)
if manifest.virtual_tools:
lines.append(
'Virtual tools: '
+ ', '.join(tool.name for tool in manifest.virtual_tools)
)
if manifest.blocked_tools:
lines.append(
'Blocked tools: '
+ ', '.join(manifest.blocked_tools)
)
blocks.append('\n'.join(lines))
return tuple(blocks)
def before_prompt_injections(self) -> tuple[str, ...]:
if not self.manifests:
return ()
injections = tuple(
manifest.before_prompt
for manifest in self.manifests
if manifest.before_prompt
)
if injections:
self.session_state['before_prompt_calls'] = int(
self.session_state.get('before_prompt_calls', 0)
) + 1
return injections
def after_turn_injections(self) -> tuple[str, ...]:
if not self.manifests:
return ()
injections = tuple(
manifest.after_turn
for manifest in self.manifests
if manifest.after_turn
)
if injections:
self.session_state['after_turn_calls'] = int(
self.session_state.get('after_turn_calls', 0)
) + 1
return injections
def on_resume_injections(self) -> tuple[str, ...]:
if not self.manifests:
return ()
injections = tuple(
manifest.on_resume
for manifest in self.manifests
if manifest.on_resume
)
if injections:
self.session_state['resume_calls'] = int(
self.session_state.get('resume_calls', 0)
) + 1
return injections
def before_persist_injections(self) -> tuple[str, ...]:
if not self.manifests:
return ()
injections = tuple(
manifest.before_persist
for manifest in self.manifests
if manifest.before_persist
)
if injections:
self.session_state['persist_calls'] = int(
self.session_state.get('persist_calls', 0)
) + 1
return injections
def before_delegate_injections(self) -> tuple[str, ...]:
if not self.manifests:
return ()
injections = tuple(
manifest.before_delegate
for manifest in self.manifests
if manifest.before_delegate
)
if injections:
self.session_state['delegate_calls'] = int(
self.session_state.get('delegate_calls', 0)
) + 1
return injections
def after_delegate_injections(self) -> tuple[str, ...]:
if not self.manifests:
return ()
return tuple(
manifest.after_delegate
for manifest in self.manifests
if manifest.after_delegate
)
def register_tool_aliases(
self,
base_registry: dict[str, Any],
) -> dict[str, Any]:
from .agent_tools import AgentTool
aliases: dict[str, AgentTool] = {}
for manifest in self.manifests:
for alias in manifest.tool_aliases:
base_tool = base_registry.get(alias.base_tool)
if base_tool is None or alias.name in base_registry or alias.name in aliases:
continue
aliases[alias.name] = AgentTool(
name=alias.name,
description=(
alias.description
or f'Plugin alias from {manifest.name} for base tool {alias.base_tool}.'
),
parameters=base_tool.parameters,
handler=base_tool.handler,
)
return aliases
def register_virtual_tools(
self,
base_registry: dict[str, Any] | None = None,
) -> dict[str, Any]:
from .agent_tools import AgentTool
tools: dict[str, AgentTool] = {}
occupied = set(base_registry or {})
for manifest in self.manifests:
for tool in manifest.virtual_tools:
if tool.name in occupied or tool.name in tools:
continue
tools[tool.name] = AgentTool(
name=tool.name,
description=tool.description,
parameters=tool.parameters or {'type': 'object', 'properties': {}},
handler=_build_virtual_tool_handler(manifest.name, tool),
)
return tools
def blocked_tool_message(self, tool_name: str) -> str | None:
for manifest in self.manifests:
if tool_name in manifest.blocked_tools:
return f'Plugin {manifest.name} blocked tool {tool_name}.'
for hook in manifest.tool_hooks:
if hook.tool_name == tool_name and hook.block_message:
return hook.block_message
return None
def tool_preflight_injections(self, tool_name: str) -> tuple[str, ...]:
messages: list[str] = []
for manifest in self.manifests:
for hook in manifest.tool_hooks:
if hook.tool_name == tool_name and hook.before_tool:
messages.append(f'{manifest.name}: {hook.before_tool}')
return tuple(messages)
def tool_result_injections(self, tool_name: str) -> tuple[str, ...]:
messages: list[str] = []
for manifest in self.manifests:
for hook in manifest.tool_hooks:
if hook.tool_name == tool_name and hook.after_result:
messages.append(f'{manifest.name}: {hook.after_result}')
return tuple(messages)
def render_summary(self) -> str:
if not self.manifests:
return 'No local plugin manifests discovered.'
lines = [f'Local plugin manifests: {len(self.manifests)}']
for manifest in self.manifests[:10]:
details = [manifest.name]
if manifest.version:
details.append(f'version={manifest.version}')
if manifest.tool_names:
details.append(f'tools={len(manifest.tool_names)}')
if manifest.hook_names:
details.append(f'hooks={len(manifest.hook_names)}')
if manifest.tool_aliases:
details.append(f'aliases={len(manifest.tool_aliases)}')
if manifest.virtual_tools:
details.append(f'virtual_tools={len(manifest.virtual_tools)}')
if manifest.blocked_tools:
details.append(f'blocked={len(manifest.blocked_tools)}')
if manifest.tool_hooks:
details.append(f'tool_hooks={len(manifest.tool_hooks)}')
lines.append(f"- {'; '.join(details)}")
if len(self.manifests) > 10:
lines.append(f'- ... plus {len(self.manifests) - 10} more plugin manifests')
if self.session_state:
lines.append(
'- runtime_state='
+ ', '.join(
f'{name}={value}'
for name, value in sorted(self.session_state.items())
if isinstance(value, (int, float, str, bool))
)
)
return '\n'.join(lines)
def record_tool_attempt(self, tool_name: str, *, blocked: bool) -> None:
attempts = int(self.session_state.get('tool_attempts', 0))
self.session_state['tool_attempts'] = attempts + 1
if blocked:
blocked_count = int(self.session_state.get('blocked_tool_attempts', 0))
self.session_state['blocked_tool_attempts'] = blocked_count + 1
counts = self.session_state.get('tool_attempt_counts')
if not isinstance(counts, dict):
counts = {}
counts[tool_name] = int(counts.get(tool_name, 0)) + 1
self.session_state['tool_attempt_counts'] = counts
def record_tool_result(
self,
tool_name: str,
*,
ok: bool,
metadata: dict[str, Any] | None = None,
) -> None:
counts = self.session_state.get('tool_result_counts')
if not isinstance(counts, dict):
counts = {}
counts[tool_name] = int(counts.get(tool_name, 0)) + 1
self.session_state['tool_result_counts'] = counts
key = 'successful_tool_results' if ok else 'failed_tool_results'
self.session_state[key] = int(self.session_state.get(key, 0)) + 1
if isinstance(metadata, dict) and metadata.get('action') == 'plugin_virtual_tool':
self.session_state['virtual_tool_results'] = int(
self.session_state.get('virtual_tool_results', 0)
) + 1
def export_session_state(self) -> dict[str, Any]:
exported: dict[str, Any] = {}
for key, value in self.session_state.items():
if isinstance(value, dict):
exported[key] = {
str(name): count
for name, count in value.items()
if isinstance(name, str)
and isinstance(count, int)
and not isinstance(count, bool)
}
elif isinstance(value, (int, float, str, bool)):
exported[key] = value
return exported
def restore_session_state(self, payload: dict[str, Any] | None) -> None:
if not isinstance(payload, dict):
self.session_state = {}
return
restored: dict[str, Any] = {}
for key, value in payload.items():
if isinstance(value, dict):
restored[key] = {
str(name): int(count)
for name, count in value.items()
if isinstance(name, str)
and isinstance(count, int)
and not isinstance(count, bool)
}
elif isinstance(value, (int, float, str, bool)):
restored[key] = value
self.session_state = restored
def runtime_state_reminder(self) -> str | None:
if not self.manifests or not self.session_state:
return None
lines = ['Plugin runtime state:']
before_prompt_calls = self.session_state.get('before_prompt_calls')
if isinstance(before_prompt_calls, int):
lines.append(f'- before_prompt_calls={before_prompt_calls}')
after_turn_calls = self.session_state.get('after_turn_calls')
if isinstance(after_turn_calls, int):
lines.append(f'- after_turn_calls={after_turn_calls}')
tool_attempts = self.session_state.get('tool_attempts')
if isinstance(tool_attempts, int):
lines.append(f'- tool_attempts={tool_attempts}')
blocked_attempts = self.session_state.get('blocked_tool_attempts')
if isinstance(blocked_attempts, int):
lines.append(f'- blocked_tool_attempts={blocked_attempts}')
resume_calls = self.session_state.get('resume_calls')
if isinstance(resume_calls, int):
lines.append(f'- resume_calls={resume_calls}')
persist_calls = self.session_state.get('persist_calls')
if isinstance(persist_calls, int):
lines.append(f'- persist_calls={persist_calls}')
delegate_calls = self.session_state.get('delegate_calls')
if isinstance(delegate_calls, int):
lines.append(f'- delegate_calls={delegate_calls}')
virtual_results = self.session_state.get('virtual_tool_results')
if isinstance(virtual_results, int):
lines.append(f'- virtual_tool_results={virtual_results}')
if len(lines) == 1:
return None
return '\n'.join(lines)
def _discover_plugin_manifest_paths(
cwd: Path,
additional_working_directories: tuple[str, ...],
) -> tuple[Path, ...]:
candidates: list[Path] = []
seen: set[Path] = set()
def remember(path: Path) -> None:
resolved = path.resolve()
if resolved in seen or not resolved.exists() or not resolved.is_file():
return
seen.add(resolved)
candidates.append(resolved)
roots = _walk_upwards(cwd.resolve())
roots.extend(Path(path).resolve() for path in additional_working_directories)
for root in roots:
remember(root / '.codex-plugin' / 'plugin.json')
remember(root / '.claw-plugin' / 'plugin.json')
plugins_dir = root / 'plugins'
if plugins_dir.is_dir():
for candidate in sorted(plugins_dir.glob('*/plugin.json')):
remember(candidate)
return tuple(candidates)
def _walk_upwards(path: Path) -> list[Path]:
walked: list[Path] = []
current = path
while True:
walked.append(current)
if current.parent == current:
break
current = current.parent
return walked
def _load_manifest(path: Path) -> PluginManifest | None:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
name = payload.get('name')
if not isinstance(name, str) or not name.strip():
return None
(
before_prompt,
after_turn,
on_resume,
before_persist,
before_delegate,
after_delegate,
hook_names,
) = _parse_hooks(payload.get('hooks'))
return PluginManifest(
name=name.strip(),
path=str(path),
version=_optional_string(payload.get('version')),
description=_optional_string(payload.get('description')),
tool_names=_extract_string_tuple(payload.get('tools')),
hook_names=hook_names,
tool_aliases=_extract_tool_aliases(payload),
virtual_tools=_extract_virtual_tools(payload),
tool_hooks=_extract_tool_hooks(payload),
blocked_tools=_extract_string_tuple(
payload.get('blocked_tools')
if payload.get('blocked_tools') is not None
else payload.get('blockedTools')
),
before_prompt=before_prompt,
after_turn=after_turn,
on_resume=on_resume,
before_persist=before_persist,
before_delegate=before_delegate,
after_delegate=after_delegate,
)
def _optional_string(value: Any) -> str | None:
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _extract_string_tuple(value: Any) -> tuple[str, ...]:
if isinstance(value, list):
return tuple(item for item in value if isinstance(item, str) and item.strip())
if isinstance(value, dict):
names = [key for key in value if isinstance(key, str) and key.strip()]
return tuple(names)
return ()
def _extract_tool_aliases(payload: dict[str, Any]) -> tuple[PluginToolAlias, ...]:
raw_aliases = payload.get('tool_aliases')
if raw_aliases is None:
raw_aliases = payload.get('toolAliases')
aliases: list[PluginToolAlias] = []
if isinstance(raw_aliases, list):
for item in raw_aliases:
if not isinstance(item, dict):
continue
name = item.get('name')
base_tool = item.get('base_tool')
if base_tool is None:
base_tool = item.get('baseTool')
if not isinstance(name, str) or not name.strip():
continue
if not isinstance(base_tool, str) or not base_tool.strip():
continue
aliases.append(
PluginToolAlias(
name=name.strip(),
base_tool=base_tool.strip(),
description=_optional_string(item.get('description')),
)
)
return tuple(aliases)
def _parse_hooks(
value: Any,
) -> tuple[
str | None,
str | None,
str | None,
str | None,
str | None,
str | None,
tuple[str, ...],
]:
if isinstance(value, list):
names = tuple(item for item in value if isinstance(item, str) and item.strip())
return None, None, None, None, None, None, names
if isinstance(value, dict):
names = tuple(key for key in value if isinstance(key, str) and key.strip())
before_prompt = value.get('beforePrompt')
if before_prompt is None:
before_prompt = value.get('before_prompt')
after_turn = value.get('afterTurn')
if after_turn is None:
after_turn = value.get('after_turn')
on_resume = value.get('onResume')
if on_resume is None:
on_resume = value.get('on_resume')
before_persist = value.get('beforePersist')
if before_persist is None:
before_persist = value.get('before_persist')
before_delegate = value.get('beforeDelegate')
if before_delegate is None:
before_delegate = value.get('before_delegate')
after_delegate = value.get('afterDelegate')
if after_delegate is None:
after_delegate = value.get('after_delegate')
return (
_optional_string(before_prompt),
_optional_string(after_turn),
_optional_string(on_resume),
_optional_string(before_persist),
_optional_string(before_delegate),
_optional_string(after_delegate),
names,
)
return None, None, None, None, None, None, ()
def _extract_tool_hooks(payload: dict[str, Any]) -> tuple[PluginToolHook, ...]:
raw_hooks = payload.get('tool_hooks')
if raw_hooks is None:
raw_hooks = payload.get('toolHooks')
hooks: list[PluginToolHook] = []
if isinstance(raw_hooks, dict):
for tool_name, value in raw_hooks.items():
if not isinstance(tool_name, str) or not tool_name.strip():
continue
if isinstance(value, str):
hooks.append(
PluginToolHook(
tool_name=tool_name.strip(),
after_result=value.strip() or None,
)
)
continue
if not isinstance(value, dict):
continue
before_tool = value.get('beforeTool')
if before_tool is None:
before_tool = value.get('before_tool')
after_result = value.get('afterResult')
if after_result is None:
after_result = value.get('after_result')
block_message = value.get('blockMessage')
if block_message is None:
block_message = value.get('block_message')
hooks.append(
PluginToolHook(
tool_name=tool_name.strip(),
before_tool=_optional_string(before_tool),
after_result=_optional_string(after_result),
block_message=_optional_string(block_message),
)
)
return tuple(hooks)
def _extract_virtual_tools(payload: dict[str, Any]) -> tuple[PluginVirtualTool, ...]:
raw_tools = payload.get('virtual_tools')
if raw_tools is None:
raw_tools = payload.get('virtualTools')
if raw_tools is None:
raw_tools = payload.get('runtimeTools')
tools: list[PluginVirtualTool] = []
if isinstance(raw_tools, list):
for item in raw_tools:
if not isinstance(item, dict):
continue
name = _optional_string(item.get('name'))
description = _optional_string(item.get('description'))
response_template = item.get('responseTemplate')
if response_template is None:
response_template = item.get('response_template')
if response_template is None:
response_template = item.get('response')
response_text = _optional_string(response_template)
parameters = item.get('parameters')
metadata = item.get('metadata')
if not name or not description or not response_text:
continue
tools.append(
PluginVirtualTool(
name=name,
description=description,
response_template=response_text,
parameters=dict(parameters) if isinstance(parameters, dict) else {},
metadata=dict(metadata) if isinstance(metadata, dict) else {},
)
)
return tuple(tools)
def _build_virtual_tool_handler(
plugin_name: str,
tool: PluginVirtualTool,
):
def _handler(arguments: dict[str, Any], context): # noqa: ANN001
rendered = _render_virtual_tool_response(tool.response_template, arguments)
metadata = {
'action': 'plugin_virtual_tool',
'plugin_name': plugin_name,
'virtual_tool': tool.name,
**tool.metadata,
}
return rendered, metadata
return _handler
def _render_virtual_tool_response(
template: str,
arguments: dict[str, Any],
) -> str:
normalized = {
key: json.dumps(value, ensure_ascii=True) if isinstance(value, (dict, list)) else str(value)
for key, value in arguments.items()
}
try:
return template.format_map(_SafeTemplateDict(normalized))
except Exception:
return template
class _SafeTemplateDict(dict[str, str]):
def __missing__(self, key: str) -> str:
return '{' + key + '}'