From 9b940006181284d43d016a525308571ff7b37412 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Mon, 2 Feb 2026 00:26:16 +0800 Subject: [PATCH 01/16] chore: stash code --- .../api/definition/components/__init__.py | 18 +++ .../components/agent_runner/__init__.py | 1 + .../components/agent_runner/runner.py | 85 +++++++++++ .../entities/builtin/agent_runner/__init__.py | 1 + .../entities/builtin/agent_runner/context.py | 59 ++++++++ src/langbot_plugin/api/proxies/langbot_api.py | 29 ++++ .../entities/io/actions/enums.py | 9 +- .../runtime/io/handlers/control.py | 26 ++++ src/langbot_plugin/runtime/plugin/mgr.py | 138 ++++++++++++++++++ 9 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 src/langbot_plugin/api/definition/components/agent_runner/__init__.py create mode 100644 src/langbot_plugin/api/definition/components/agent_runner/runner.py create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/context.py diff --git a/src/langbot_plugin/api/definition/components/__init__.py b/src/langbot_plugin/api/definition/components/__init__.py index e69de29b..08e0b41c 100644 --- a/src/langbot_plugin/api/definition/components/__init__.py +++ b/src/langbot_plugin/api/definition/components/__init__.py @@ -0,0 +1,18 @@ +"""Components for LangBot plugins.""" + +from langbot_plugin.api.definition.components.base import BaseComponent, PolymorphicComponent +from langbot_plugin.api.definition.components.command.command import Command +from langbot_plugin.api.definition.components.tool.tool import Tool +from langbot_plugin.api.definition.components.common.event_listener import EventListener +from langbot_plugin.api.definition.components.knowledge_retriever.retriever import KnowledgeRetriever +from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner + +__all__ = [ + "BaseComponent", + "PolymorphicComponent", + "Command", + "Tool", + "EventListener", + "KnowledgeRetriever", + "AgentRunner", +] diff --git a/src/langbot_plugin/api/definition/components/agent_runner/__init__.py b/src/langbot_plugin/api/definition/components/agent_runner/__init__.py new file mode 100644 index 00000000..7a56be33 --- /dev/null +++ b/src/langbot_plugin/api/definition/components/agent_runner/__init__.py @@ -0,0 +1 @@ +"""Agent Runner component.""" diff --git a/src/langbot_plugin/api/definition/components/agent_runner/runner.py b/src/langbot_plugin/api/definition/components/agent_runner/runner.py new file mode 100644 index 00000000..f3935e33 --- /dev/null +++ b/src/langbot_plugin/api/definition/components/agent_runner/runner.py @@ -0,0 +1,85 @@ +"""Agent Runner component definition.""" +from __future__ import annotations + +import abc +from typing import AsyncGenerator + +from langbot_plugin.api.definition.components.base import BaseComponent +from langbot_plugin.api.entities.builtin.agent_runner import context + + +class AgentRunner(BaseComponent): + """Agent Runner component base class. + + AgentRunner is responsible for processing user messages and generating responses. + It can use LLM models, tools, and knowledge bases to generate intelligent responses. + + Unlike Tool or Command components, AgentRunner is not polymorphic - + a plugin can only provide one AgentRunner implementation. + + Example: + ```python + from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner + from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext, AgentRunReturn + + class MyAgentRunner(AgentRunner): + async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunReturn, None]: + # Use LLM + response = await self.plugin.invoke_llm( + llm_model_uuid=self.config.get('llm_model_uuid'), + messages=ctx.messages + [ + Message(role='user', content=str(ctx.user_message)) + ] + ) + + yield AgentRunReturn( + type='finish', + message=response, + finish_reason='stop' + ) + ``` + """ + + __kind__ = "AgentRunner" + + @abc.abstractmethod + async def run( + self, ctx: context.AgentRunContext + ) -> AsyncGenerator[context.AgentRunReturn, None]: + """Run the agent to process a user message. + + Args: + ctx: Agent run context containing: + - query_id: Unique ID for this request + - session: Session information (launcher_type, launcher_id, sender_id) + - messages: Historical conversation messages + - user_message: Current user message to process + - use_funcs: Available tools the agent can use + - extra_config: Extra configuration from pipeline config + + Yields: + AgentRunReturn: Yields progress updates and final result: + - type='chunk': Partial text content (for streaming output) + - type='text': Complete text segment + - type='tool_call': Agent is calling a tool + - type='finish': Final response with complete message + + Example: + ```python + async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunReturn, None]: + # Stream response from LLM + async for chunk in self.plugin.invoke_llm_stream(...): + yield AgentRunReturn( + type='chunk', + message_chunk=chunk + ) + + # Indicate completion + yield AgentRunReturn( + type='finish', + message=final_message, + finish_reason='stop' + ) + ``` + """ + pass diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py b/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py new file mode 100644 index 00000000..e40f42cd --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py @@ -0,0 +1 @@ +"""Agent Runner entities.""" diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/context.py b/src/langbot_plugin/api/entities/builtin/agent_runner/context.py new file mode 100644 index 00000000..1fef7008 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/context.py @@ -0,0 +1,59 @@ +"""Agent Runner context entities.""" +from __future__ import annotations + +import typing +import pydantic + +from langbot_plugin.api.entities.builtin.provider import message as provider_message +from langbot_plugin.api.entities.builtin.provider import session as provider_session +from langbot_plugin.api.entities.builtin.resource import tool as resource_tool + + +class AgentRunContext(pydantic.BaseModel): + """Agent run context passed to AgentRunner.run()""" + + query_id: int + """Query ID for this request""" + + session: provider_session.Session + """Session information""" + + messages: list[provider_message.Message] + """Historical messages in the conversation""" + + user_message: provider_message.ContentElement + """Current user message""" + + use_funcs: list[resource_tool.LLMTool] + """Available tools for the agent to use""" + + extra_config: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Extra configuration from pipeline config""" + + class Config: + arbitrary_types_allowed = True + + +class AgentRunReturn(pydantic.BaseModel): + """Return value from AgentRunner.run()""" + + type: str + """Return type: 'text' | 'chunk' | 'tool_call' | 'finish'""" + + content: typing.Optional[str] = None + """Text content for 'text' and 'chunk' types""" + + message: typing.Optional[provider_message.Message] = None + """Complete message for 'finish' type""" + + message_chunk: typing.Optional[provider_message.MessageChunk] = None + """Message chunk for 'chunk' type""" + + tool_calls: typing.Optional[list[provider_message.ToolCall]] = None + """Tool calls for 'tool_call' type""" + + finish_reason: typing.Optional[str] = None + """Finish reason for 'finish' type: 'stop' | 'length' | 'tool_calls' | 'error'""" + + class Config: + arbitrary_types_allowed = True diff --git a/src/langbot_plugin/api/proxies/langbot_api.py b/src/langbot_plugin/api/proxies/langbot_api.py index 572a1e45..489b4b57 100644 --- a/src/langbot_plugin/api/proxies/langbot_api.py +++ b/src/langbot_plugin/api/proxies/langbot_api.py @@ -101,6 +101,35 @@ async def invoke_llm( return provider_message.Message.model_validate(resp) + async def invoke_llm_stream( + self, + llm_model_uuid: str, + messages: list[provider_message.Message], + funcs: list[resource_tool.LLMTool] = [], + extra_args: dict[str, Any] = {}, + ): + """Invoke an LLM model with streaming response + + Args: + llm_model_uuid: The UUID of the LLM model to use + messages: List of conversation messages + funcs: List of tools available to the LLM + extra_args: Extra arguments for the LLM provider + + Yields: + MessageChunk: Streamed message chunks from the LLM + """ + async for chunk_data in self.plugin_runtime_handler.call_action_generator( + PluginToRuntimeAction.INVOKE_LLM_STREAM, + { + "llm_model_uuid": llm_model_uuid, + "messages": [m.model_dump() for m in messages], + "funcs": [f.model_dump() for f in funcs], + "extra_args": extra_args, + }, + ): + yield provider_message.MessageChunk.model_validate(chunk_data["chunk"]) + async def set_plugin_storage(self, key: str, value: bytes) -> None: """Set a plugin storage value""" await self.plugin_runtime_handler.call_action( diff --git a/src/langbot_plugin/entities/io/actions/enums.py b/src/langbot_plugin/entities/io/actions/enums.py index d34c0a68..fe82a352 100644 --- a/src/langbot_plugin/entities/io/actions/enums.py +++ b/src/langbot_plugin/entities/io/actions/enums.py @@ -40,7 +40,7 @@ class PluginToRuntimeAction(ActionType): GET_LLM_MODELS = "get_llm_models" # GET_LLM_MODEL_INFO = "get_llm_model_info" INVOKE_LLM = "invoke_llm" - # INVOKE_LLM_STREAMING = "invoke_llm_streaming" + INVOKE_LLM_STREAM = "invoke_llm_stream" SET_PLUGIN_STORAGE = "set_plugin_storage" GET_PLUGIN_STORAGE = "get_plugin_storage" @@ -122,7 +122,12 @@ class LangBotToRuntimeAction(ActionType): LIST_COMMANDS = "list_commands" EXECUTE_COMMAND = "execute_command" - # RAG actions + # AgentRunner actions + LIST_AGENT_RUNNERS = "list_agent_runners" + RUN_AGENT = "run_agent" + + # Knowledge Retriever actions + LIST_KNOWLEDGE_RETRIEVERS = "list_knowledge_retrievers" RETRIEVE_KNOWLEDGE = "retrieve_knowledge" # Knowledge Engine actions (LangBot -> Runtime -> Plugin) diff --git a/src/langbot_plugin/runtime/io/handlers/control.py b/src/langbot_plugin/runtime/io/handlers/control.py index 942373b1..ad50cc03 100644 --- a/src/langbot_plugin/runtime/io/handlers/control.py +++ b/src/langbot_plugin/runtime/io/handlers/control.py @@ -251,6 +251,32 @@ async def execute_command( ): yield handler.ActionResponse.success(resp.model_dump(mode="json")) + # AgentRunner actions + @self.action(LangBotToRuntimeAction.LIST_AGENT_RUNNERS) + async def list_agent_runners(data: dict[str, Any]) -> handler.ActionResponse: + include_plugins = data.get("include_plugins") + runners = await self.context.plugin_mgr.list_agent_runners(include_plugins) + return handler.ActionResponse.success({"runners": runners}) + + @self.action(LangBotToRuntimeAction.RUN_AGENT) + async def run_agent(data: dict[str, Any]) -> AsyncGenerator[handler.ActionResponse, None]: + plugin_author = data["plugin_author"] + plugin_name = data["plugin_name"] + runner_name = data["runner_name"] + context = data["context"] + + async for result in self.context.plugin_mgr.run_agent( + plugin_author, plugin_name, runner_name, context + ): + yield handler.ActionResponse.success(result) + + # KnowledgeRetriever actions + @self.action(LangBotToRuntimeAction.LIST_KNOWLEDGE_RETRIEVERS) + async def list_knowledge_retrievers(data: dict[str, Any]) -> handler.ActionResponse: + include_plugins = data.get("include_plugins") + retrievers = await self.context.plugin_mgr.list_knowledge_retrievers(include_plugins) + return handler.ActionResponse.success({"retrievers": retrievers}) + @self.action(LangBotToRuntimeAction.RETRIEVE_KNOWLEDGE) async def retrieve_knowledge(data: dict[str, Any]) -> handler.ActionResponse: plugin_author = data["plugin_author"] diff --git a/src/langbot_plugin/runtime/plugin/mgr.py b/src/langbot_plugin/runtime/plugin/mgr.py index ed874847..efca03b3 100644 --- a/src/langbot_plugin/runtime/plugin/mgr.py +++ b/src/langbot_plugin/runtime/plugin/mgr.py @@ -802,6 +802,144 @@ async def execute_command( break + # AgentRunner methods + async def list_agent_runners( + self, include_plugins: list[str] | None = None + ) -> list[dict[str, typing.Any]]: + """List all available AgentRunner components from plugins.""" + from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner + + runners: list[dict[str, typing.Any]] = [] + + for plugin in self.plugins: + # Filter by include_plugins if specified + if include_plugins is not None: + plugin_id = ( + f"{plugin.manifest.metadata.author}/{plugin.manifest.metadata.name}" + ) + if plugin_id not in include_plugins: + continue + + for component in plugin.components: + if component.manifest.kind == AgentRunner.__kind__: + runners.append( + { + "plugin_author": plugin.manifest.metadata.author, + "plugin_name": plugin.manifest.metadata.name, + "runner_name": component.manifest.metadata.name, + "runner_description": component.manifest.metadata.description, + "manifest": component.manifest.model_dump(), + } + ) + + return runners + + async def run_agent( + self, + plugin_author: str, + plugin_name: str, + runner_name: str, + context: dict[str, typing.Any], + ) -> typing.AsyncGenerator[dict[str, typing.Any], None]: + """Run an AgentRunner component.""" + from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner + from langbot_plugin.api.entities.builtin.agent_runner.context import ( + AgentRunContext, + ) + + # Find the plugin + target_plugin = None + for plugin in self.plugins: + if ( + plugin.manifest.metadata.author == plugin_author + and plugin.manifest.metadata.name == plugin_name + ): + target_plugin = plugin + break + + if target_plugin is None: + yield { + "type": "finish", + "finish_reason": "error", + "content": f"Plugin {plugin_author}/{plugin_name} not found", + } + return + + # Find the component + target_component = None + for component in target_plugin.components: + if ( + component.manifest.kind == AgentRunner.__kind__ + and component.manifest.metadata.name == runner_name + ): + target_component = component + break + + if target_component is None: + yield { + "type": "finish", + "finish_reason": "error", + "content": f"AgentRunner {runner_name} not found in plugin {plugin_author}/{plugin_name}", + } + return + + # Get the runner instance + runner_instance = target_component.python_component_inst + + if runner_instance is None: + yield { + "type": "finish", + "finish_reason": "error", + "content": f"AgentRunner {runner_name} not initialized", + } + return + + # Parse context + run_context = AgentRunContext.model_validate(context) + + # Run the agent + try: + async for result in runner_instance.run(run_context): + yield result.model_dump() + except Exception as e: + import traceback + + traceback.print_exc() + yield { + "type": "finish", + "finish_reason": "error", + "content": f"Error running agent: {e}", + } + + # KnowledgeRetriever methods + async def list_knowledge_retrievers( + self, include_plugins: list[str] | None = None + ) -> list[dict[str, typing.Any]]: + """List all available KnowledgeRetriever components from plugins.""" + retrievers: list[dict[str, typing.Any]] = [] + + for plugin in self.plugins: + if include_plugins is not None: + plugin_id = ( + f"{plugin.manifest.metadata.author}/{plugin.manifest.metadata.name}" + ) + if plugin_id not in include_plugins: + continue + + for component in plugin.components: + if component.manifest.kind == "KnowledgeRetriever": + retrievers.append( + { + "plugin_author": plugin.manifest.metadata.author, + "plugin_name": plugin.manifest.metadata.name, + "retriever_name": component.manifest.metadata.name, + "retriever_description": component.manifest.metadata.description, + "manifest": component.manifest.model_dump(), + } + ) + + return retrievers + async def retrieve_knowledge( self, plugin_author: str, From 2b71ead1b5502922d2c7a92f3553c6e8f8cba7ae Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Sun, 10 May 2026 10:11:31 +0800 Subject: [PATCH 02/16] feat(agent-runner): implement AgentRunner Protocol v1 runtime support Phase 0 integration complete - LangBot + SDK + runner repo minimal loop verified. Key changes: - Add RUN_AGENT action forwarding from runtime to plugin process - Add AgentRunner to preinitialize_component_classes for initialization - Implement Protocol v1 entities: AgentRunContext, AgentRunResult, capabilities, permissions - Add resp_message_id field to Message class for LangBot integration - Fix AgentInput.message_chain to accept list type (matches LangBot data) - Add comprehensive tests for AgentRunner protocol Integration verified: plugin:langbot/local-agent/default returns [stub] Echo response. Co-Authored-By: Claude Opus 4.7 --- .../PHASE0_INTEGRATION_LOG.md | 94 ++++ .../agent-runner-pluginization/PROTOCOL_V1.md | 376 ++++++++++++++ .../SDK_RUNTIME_PLAN.md | 334 ++++++++++++ .../api/definition/components/__init__.py | 7 +- .../components/agent_runner/runner.py | 145 ++++-- .../entities/builtin/agent_runner/__init__.py | 60 ++- .../builtin/agent_runner/capabilities.py | 37 ++ .../entities/builtin/agent_runner/context.py | 85 ++-- .../entities/builtin/agent_runner/event.py | 76 +++ .../entities/builtin/agent_runner/input.py | 42 ++ .../entities/builtin/agent_runner/legacy.py | 262 ++++++++++ .../builtin/agent_runner/permissions.py | 44 ++ .../builtin/agent_runner/resources.py | 97 ++++ .../entities/builtin/agent_runner/result.py | 163 ++++++ .../entities/builtin/agent_runner/runtime.py | 31 ++ .../entities/builtin/agent_runner/trigger.py | 22 + .../api/entities/builtin/provider/message.py | 3 + .../components/agent_runner/__init__.py | 0 .../agent_runner/{runner_name}.py.example | 83 +++ .../agent_runner/{runner_name}.yaml.example | 33 ++ src/langbot_plugin/cli/gen/renderer.py | 65 +++ src/langbot_plugin/cli/run/controller.py | 14 + src/langbot_plugin/cli/run/handler.py | 59 +++ .../entities/io/actions/enums.py | 7 +- .../runtime/io/handlers/control.py | 31 +- src/langbot_plugin/runtime/plugin/mgr.py | 176 ++++--- .../api/definition/components/test_imports.py | 62 +++ .../agent_runner/test_context_result.py | 355 +++++++++++++ tests/runtime/plugin/test_mgr_agent_runner.py | 478 ++++++++++++++++++ 29 files changed, 3036 insertions(+), 205 deletions(-) create mode 100644 docs/agent-runner-pluginization/PHASE0_INTEGRATION_LOG.md create mode 100644 docs/agent-runner-pluginization/PROTOCOL_V1.md create mode 100644 docs/agent-runner-pluginization/SDK_RUNTIME_PLAN.md create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/capabilities.py create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/event.py create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/input.py create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/legacy.py create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/permissions.py create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/resources.py create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/result.py create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/runtime.py create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/trigger.py create mode 100644 src/langbot_plugin/assets/templates/components/agent_runner/__init__.py create mode 100644 src/langbot_plugin/assets/templates/components/agent_runner/{runner_name}.py.example create mode 100644 src/langbot_plugin/assets/templates/components/agent_runner/{runner_name}.yaml.example create mode 100644 tests/api/definition/components/test_imports.py create mode 100644 tests/api/entities/builtin/agent_runner/test_context_result.py create mode 100644 tests/runtime/plugin/test_mgr_agent_runner.py diff --git a/docs/agent-runner-pluginization/PHASE0_INTEGRATION_LOG.md b/docs/agent-runner-pluginization/PHASE0_INTEGRATION_LOG.md new file mode 100644 index 00000000..f3430ddd --- /dev/null +++ b/docs/agent-runner-pluginization/PHASE0_INTEGRATION_LOG.md @@ -0,0 +1,94 @@ +# AgentRunner Pluginization Phase 0 聃调记录 + +## 时间 + +2026-05-10 10:09 + +## 参与仓库 + +- **langbot-plugin-sdk**: feat/agent-runner-plugin 分支 +- **LangBot**: feat/agent-runner-plugin 分支 +- **langbot-agent-runner**: feat/agent-runner-plugin 分支 + +## 聃调配置 + +### Runner 选择 + +``` +plugin:langbot/local-agent/default +``` + +### 输入 + +``` +1 +``` + +### 输出 + +``` +[stub] Echo: 1 +``` + +## 验证链路 + +``` +前端选择 plugin:langbot/local-agent/default + -> LangBot pipeline + -> AgentRunOrchestrator + -> SDK runtime RUN_AGENT + -> langbot-agent-runner/local-agent DefaultAgentRunner + -> AgentRunResult + -> LangBot response +``` + +## 协议要点 + +### LIST_AGENT_RUNNERS 响应 (v1) + +```json +{ + "runners": [ + { + "plugin_author": "langbot", + "plugin_name": "local-agent", + "runner_name": "default", + "manifest": { ... }, + "protocol_version": "1", + "capabilities": { ... }, + "permissions": { ... }, + "config": [] + } + ] +} +``` + +### RUN_AGENT 流式响应 (v1) + +```json +{ + "type": "run.completed", + "data": { + "message": { + "role": "assistant", + "content": "[stub] Echo: 1" + }, + "finish_reason": "stop" + } +} +``` + +## 结论 + +LangBot + SDK + runner repo Phase 0 聃调通过。 + +这是最小协议闭环,证明新 AgentRunner 插件化主链路可运行。 + +## 下一步 + +1. **Phase 1: 迁 Dify** - 让 dify-agent 从 stub 变成真实实现 +2. **LangBot 后续项**: + - 前端保存新格式 `ai.runner.id` / `ai.runner_config` + - 持久化 migration + - 模板 `ai.yaml/default-pipeline-config.json` 更新 + - proxy action 二次权限校验 \ No newline at end of file diff --git a/docs/agent-runner-pluginization/PROTOCOL_V1.md b/docs/agent-runner-pluginization/PROTOCOL_V1.md new file mode 100644 index 00000000..5fd7e6c5 --- /dev/null +++ b/docs/agent-runner-pluginization/PROTOCOL_V1.md @@ -0,0 +1,376 @@ +# AgentRunner Protocol v1 + +本文档固定 AgentRunner 插件协议 v1。LangBot 和 SDK/runtime 实现都应以本文档为准。 + +## 1. Runner ID + +LangBot 侧稳定 id: + +```text +plugin:{plugin_author}/{plugin_name}/{runner_name} +``` + +示例: + +```text +plugin:langbot/local-agent/default +plugin:langbot/dify-agent/default +plugin:alice/helpdesk-agent/ticket +``` + +约束: + +- `plugin_author`、`plugin_name` 来自插件 manifest metadata。 +- `runner_name` 来自 AgentRunner component manifest metadata。 +- 一个插件可以暴露多个 runner。 + +## 2. Component Manifest + +最小 manifest: + +```yaml +apiVersion: langbot/v1 +kind: AgentRunner +metadata: + name: default + label: + en_US: Default Agent + zh_Hans: 默认 Agent + description: + en_US: Default AgentRunner. + zh_Hans: 默认 AgentRunner。 +spec: + protocol_version: "1" + config: [] + capabilities: {} + permissions: {} +execution: + python: + path: ./main.py + attr: DefaultAgentRunner +``` + +`spec.config` 使用 LangBot DynamicForm schema。 + +## 3. Capabilities + +默认值全部为 `false`。 + +```python +class AgentRunnerCapabilities(BaseModel): + streaming: bool = False + tool_calling: bool = False + knowledge_retrieval: bool = False + multimodal_input: bool = False + event_context: bool = False + platform_api: bool = False + interrupt: bool = False + stateful_session: bool = False +``` + +含义: + +- `streaming`: runner 可能输出 `message.delta` +- `tool_calling`: runner 需要 tool list/detail/call +- `knowledge_retrieval`: runner 需要知识库列表或检索 +- `multimodal_input`: runner 能处理 image/file/audio 等非纯文本输入 +- `event_context`: runner 会读取 `ctx.event/actor/subject` +- `platform_api`: runner 未来可能请求平台动作,本阶段不执行 +- `interrupt`: runner 支持取消或中断 +- `stateful_session`: runner 会维护外部 conversation/session state + +## 4. Permissions + +默认值全部为空 list。 + +```python +class AgentRunnerPermissions(BaseModel): + models: list[Literal["list", "invoke", "stream", "embedding"]] = [] + tools: list[Literal["list", "detail", "call"]] = [] + knowledge_bases: list[Literal["list", "retrieve"]] = [] + storage: list[Literal["plugin", "workspace"]] = [] + files: list[Literal["config", "knowledge"]] = [] + platform_api: list[str] = [] +``` + +权限只是 runner 请求的上限。LangBot 执行时还要结合 Pipeline/Bot 绑定范围和用户配置裁剪成 `ctx.resources`。 + +## 5. AgentRunContext + +```python +class AgentRunContext(BaseModel): + run_id: str + trigger: AgentTrigger + conversation: ConversationContext | None = None + event: AgentEventContext | None = None + actor: ActorContext | None = None + subject: SubjectContext | None = None + messages: list[Message] = [] + input: AgentInput + resources: AgentResources + runtime: AgentRuntimeContext + config: dict[str, Any] = {} +``` + +### 5.1 AgentTrigger + +```python +class AgentTrigger(BaseModel): + type: str + source: Literal["pipeline", "event_router"] = "pipeline" + timestamp: int | None = None +``` + +当前 Pipeline 使用: + +```json +{"type": "message.received", "source": "pipeline"} +``` + +### 5.2 ConversationContext + +```python +class ConversationContext(BaseModel): + session_id: str | None = None + conversation_id: str | None = None + launcher_type: str | None = None + launcher_id: str | None = None + sender_id: str | None = None + bot_uuid: str | None = None + pipeline_uuid: str | None = None +``` + +### 5.3 AgentInput + +```python +class AgentInput(BaseModel): + text: str | None = None + contents: list[ContentElement] = [] + message_chain: dict[str, Any] | None = None + attachments: list[dict[str, Any]] = [] + + def to_text(self) -> str: ... +``` + +### 5.4 AgentResources + +```python +class AgentResources(BaseModel): + models: list[ModelResource] = [] + tools: list[ToolResource] = [] + knowledge_bases: list[KnowledgeBaseResource] = [] + files: list[FileResource] = [] + storage: StorageResource = StorageResource() + platform_capabilities: dict[str, Any] = {} +``` + +Resource 只表示“可见和可请求”。真正调用时 LangBot host 仍必须校验。 + +### 5.5 AgentRuntimeContext + +```python +class AgentRuntimeContext(BaseModel): + langbot_version: str | None = None + sdk_protocol_version: str = "1" + query_id: int | None = None + trace_id: str | None = None + deadline_at: int | None = None + metadata: dict[str, Any] = {} +``` + +## 6. AgentRunResult + +```python +class AgentRunResult(BaseModel): + type: Literal[ + "message.delta", + "message.completed", + "tool.call.started", + "tool.call.completed", + "state.updated", + "run.completed", + "run.failed", + "action.requested", + ] + data: dict[str, Any] = {} +``` + +### 6.1 message.delta + +```json +{ + "type": "message.delta", + "data": { + "chunk": { + "role": "assistant", + "content": "partial text" + } + } +} +``` + +LangBot 映射为 `MessageChunk`。 + +### 6.2 message.completed + +```json +{ + "type": "message.completed", + "data": { + "message": { + "role": "assistant", + "content": "final text" + } + } +} +``` + +LangBot 映射为 `Message`。 + +### 6.3 tool.call.started + +```json +{ + "type": "tool.call.started", + "data": { + "tool_call_id": "call_1", + "tool_name": "weather", + "parameters": {} + } +} +``` + +当前 Pipeline 不展示,LangBot 记录 telemetry/debug。 + +### 6.4 tool.call.completed + +```json +{ + "type": "tool.call.completed", + "data": { + "tool_call_id": "call_1", + "tool_name": "weather", + "result": {}, + "error": null + } +} +``` + +当前 Pipeline 不展示,LangBot 记录 telemetry/debug。 + +### 6.5 state.updated + +```json +{ + "type": "state.updated", + "data": { + "key": "external_conversation_id", + "value": "abc" + } +} +``` + +本阶段只记录,不要求 LangBot 自动持久化。官方插件应优先使用 plugin storage。 + +### 6.6 run.completed + +```json +{ + "type": "run.completed", + "data": { + "message": { + "role": "assistant", + "content": "done" + }, + "finish_reason": "stop" + } +} +``` + +如果带 message,LangBot 可以映射为最终 `Message`。如果之前已经输出 `message.completed`,可以不带 message。 + +### 6.7 run.failed + +```json +{ + "type": "run.failed", + "data": { + "error": "upstream timeout", + "code": "upstream.timeout", + "retryable": true + } +} +``` + +LangBot 按当前 Pipeline 错误策略返回用户提示。 + +### 6.8 action.requested + +```json +{ + "type": "action.requested", + "data": { + "action": "platform.message.edit", + "parameters": {} + } +} +``` + +本阶段不执行,只记录 telemetry。真正执行平台动作等待 EBA EventRouter 和统一平台 API。 + +## 7. LangBotToRuntime Actions + +### 7.1 LIST_AGENT_RUNNERS + +请求: + +```json +{ + "include_plugins": ["langbot/local-agent"] +} +``` + +响应: + +```json +{ + "runners": [ + { + "plugin_author": "langbot", + "plugin_name": "local-agent", + "runner_name": "default", + "manifest": {} + } + ] +} +``` + +### 7.2 RUN_AGENT + +请求: + +```json +{ + "plugin_author": "langbot", + "plugin_name": "local-agent", + "runner_name": "default", + "context": {} +} +``` + +响应是流式 `AgentRunResult`。 + +Runtime 必须把异常转换为 `run.failed`,不得让 generator 异常直接泄漏给 LangBot。 + +## 8. 兼容和废弃 + +废弃: + +- `AgentRunReturn` +- `type == chunk` +- `type == text` +- `type == tool_call` +- `type == finish` +- query 视角 context 作为主协议 + +允许 SDK 提供 legacy helper,但 LangBot 新实现只发送和接收 v1。 diff --git a/docs/agent-runner-pluginization/SDK_RUNTIME_PLAN.md b/docs/agent-runner-pluginization/SDK_RUNTIME_PLAN.md new file mode 100644 index 00000000..47adab0c --- /dev/null +++ b/docs/agent-runner-pluginization/SDK_RUNTIME_PLAN.md @@ -0,0 +1,334 @@ +# AgentRunner SDK 与 Runtime 实现计划 + +本文档面向 SDK/runtime 实现 agent。目标是把当前 PoC 级 AgentRunner 直接切到协议 v1,而不是继续兼容旧 `AgentRunContext(query_id/session/messages/user_message/extra_config)` 和 `AgentRunReturn(type=chunk/text/finish)`。 + +## 1. 最终状态 + +SDK 提供稳定的 AgentRunner 开发接口: + +- `AgentRunner` 组件基类 +- `AgentRunnerCapabilities` +- `AgentRunnerPermissions` +- `AgentRunContext` +- `AgentRunResult` +- 资源描述实体:models/tools/knowledge/files/storage/platform +- runtime action: + - `LIST_AGENT_RUNNERS` + - `RUN_AGENT` +- plugin proxy 能力分组和权限校验输入 + +Runtime 负责: + +- 发现一个插件内的多个 AgentRunner 组件 +- 返回完整 manifest/spec 给 LangBot registry +- 执行指定 runner +- 将插件异常转换为 `run.failed` +- 以流式 generator 透传 `AgentRunResult` + +## 2. 需要替换的 PoC 设计 + +当前 SDK 仓库已有 PoC: + +- `api/definition/components/agent_runner/runner.py` + - 文档中写着一个插件只能提供一个 AgentRunner,这需要改掉。 +- `api/entities/builtin/agent_runner/context.py` + - 上下文仍是 query 视角。 + - 返回类型仍是 `AgentRunReturn`,type 是 `chunk/text/tool_call/finish`。 +- `runtime/plugin/mgr.py` + - 已有 `list_agent_runners()` / `run_agent()`,但返回和错误仍是旧协议。 +- `runtime/io/handlers/control.py` + - 已有 action 壳,可以保留入口,改协议。 + +实现时不要在旧实体上小修小补。直接迁到 v1,并保留 helper 方法辅助官方插件迁移。 + +## 3. 目录和文件计划 + +建议结构: + +```text +src/langbot_plugin/api/definition/components/agent_runner/ + __init__.py + runner.py + +src/langbot_plugin/api/entities/builtin/agent_runner/ + __init__.py + capabilities.py + context.py + event.py + input.py + resources.py + result.py + runtime.py + legacy.py +``` + +必要修改: + +- `src/langbot_plugin/api/definition/components/__init__.py` +- `src/langbot_plugin/entities/io/actions/enums.py` +- `src/langbot_plugin/runtime/io/handlers/control.py` +- `src/langbot_plugin/runtime/plugin/mgr.py` +- `src/langbot_plugin/runtime/io/handlers/plugin.py` +- `src/langbot_plugin/api/proxies/langbot_api.py` +- `src/langbot_plugin/cli/commands/gencomponent.py` +- 插件模板目录 + +## 4. AgentRunner 组件接口 + +目标接口: + +```python +class AgentRunner(BaseComponent): + __kind__ = "AgentRunner" + __protocol_version__ = "1" + + @classmethod + def get_capabilities(cls) -> AgentRunnerCapabilities: + return AgentRunnerCapabilities() + + @classmethod + def get_config_schema(cls) -> list[dict[str, Any]]: + return [] + + @classmethod + def get_permissions(cls) -> AgentRunnerPermissions: + return AgentRunnerPermissions() + + async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: + raise NotImplementedError +``` + +注意: + +- 一个插件可以有多个 AgentRunner 组件。 +- 每个 runner 组件通过自己的 component manifest 暴露 name/config/capabilities/permissions。 +- classmethod 作为 Python 侧默认值;manifest 中显式声明优先。 +- `AgentRunReturn` 更名为 `AgentRunResult`。不要在新文档中继续使用 Return。 + +## 5. Manifest spec + +AgentRunner component manifest 的 `spec` 至少支持: + +```yaml +spec: + protocol_version: "1" + config: [] + capabilities: + streaming: true + tool_calling: true + knowledge_retrieval: true + multimodal_input: false + event_context: true + platform_api: false + interrupt: false + stateful_session: true + permissions: + models: ["list", "invoke", "stream", "embedding"] + tools: ["list", "detail", "call"] + knowledge_bases: ["list", "retrieve"] + storage: ["plugin", "workspace"] + files: ["config", "knowledge"] + platform_api: [] +``` + +Runtime discovery 输出时必须包含原始 component manifest,并保证 `spec.protocol_version`、`spec.config`、`spec.capabilities`、`spec.permissions` 有默认值。 + +## 6. AgentRunContext v1 + +目标字段: + +```python +class AgentRunContext(BaseModel): + run_id: str + trigger: AgentTrigger + conversation: ConversationContext | None = None + event: AgentEventContext | None = None + actor: ActorContext | None = None + subject: SubjectContext | None = None + messages: list[provider_message.Message] = Field(default_factory=list) + input: AgentInput + resources: AgentResources + runtime: AgentRuntimeContext + config: dict[str, Any] = Field(default_factory=dict) +``` + +约束: + +- `run_id` 是本次 runner 调用 id。 +- `trigger.type` 当前消息 Pipeline 使用 `message.received`。 +- `conversation` 承载 launcher/sender/bot/pipeline/history 语义。 +- `event` 是事件 envelope 子集,用于未来 EBA。 +- `input` 是主输入,支持 text、content elements、attachments、raw message chain。 +- `resources` 是 LangBot 已授权资源列表。 +- `runtime` 提供 host、workspace、bot、pipeline、query、trace、deadline。 +- `config` 是当前 runner 实例配置。 + +Legacy helper: + +```python +ctx.input.to_text() +ctx.conversation.to_legacy_session() +ctx.to_legacy_query_context() +``` + +helper 只能用于官方插件迁移,不能作为 LangBot 构造 context 的目标模型。 + +## 7. AgentRunResult v1 + +目标类型: + +```python +AgentRunResult.type in [ + "message.delta", + "message.completed", + "tool.call.started", + "tool.call.completed", + "state.updated", + "run.completed", + "run.failed", + "action.requested", +] +``` + +建议实体: + +```python +class AgentRunResult(BaseModel): + type: AgentRunResultType + data: dict[str, Any] = Field(default_factory=dict) +``` + +便捷构造器: + +```python +AgentRunResult.message_delta(chunk: MessageChunk) +AgentRunResult.message_completed(message: Message) +AgentRunResult.tool_call_started(...) +AgentRunResult.tool_call_completed(...) +AgentRunResult.state_updated(...) +AgentRunResult.run_completed(message: Message | None = None) +AgentRunResult.run_failed(error: str, code: str | None = None) +``` + +兼容策略: + +- 不再推荐 `chunk/text/tool_call/finish`。 +- 如果必须短期兼容旧插件,可在 runtime 内部增加 legacy normalizer,但必须标记 deprecated,并在 LangBot 侧只接收 v1。 + +## 8. Runtime discovery + +`PluginManager.list_agent_runners(include_plugins)`: + +- 过滤 include_plugins。 +- 遍历 plugin.components。 +- 找到 `component.manifest.kind == "AgentRunner"`。 +- 返回: + - `plugin_author` + - `plugin_name` + - `runner_name` + - `manifest` + - `protocol_version` + - `capabilities` + - `permissions` + +不要限制一个插件只能有一个 runner。 + +## 9. Runtime run_agent + +`PluginManager.run_agent(plugin_author, plugin_name, runner_name, context)`: + +执行流程: + +1. 找 plugin。 +2. 找 component kind/name。 +3. 校验 component initialized。 +4. `AgentRunContext.model_validate(context)`。 +5. 调用 `runner_instance.run(ctx)`。 +6. 对每个 result 执行 `AgentRunResult.model_validate(result)`。 +7. `yield result.model_dump(mode="json")`。 +8. 任何异常转为: + +```json +{ + "type": "run.failed", + "data": { + "error": "...", + "code": "runner.exception" + } +} +``` + +不得返回旧 `finish/error`。 + +## 10. RuntimeToPluginAction + +当前 `RUN_AGENT` 是 LangBot -> Runtime -> Python object 直接调用,不一定需要 Runtime -> Plugin action。只有在 AgentRunner 需要跨进程 action 调用时才新增。 + +本轮建议保持现有 runtime manager 直接调用已加载 component instance 的方式,减少协议面。 + +## 11. LangBotAPIProxy 能力分组 + +现有 proxy API 很宽。AgentRunner 场景需要显式资源授权。 + +建议新增: + +```python +class AgentRuntimeAPIProxy: + async def list_models(ctx: AgentRunContext) -> list[ModelResource]: ... + async def invoke_llm(ctx: AgentRunContext, model_id: str, ...): ... + async def invoke_llm_stream(ctx: AgentRunContext, model_id: str, ...): ... + async def list_tools(ctx: AgentRunContext) -> list[ToolResource]: ... + async def call_tool(ctx: AgentRunContext, tool_name: str, parameters: dict): ... + async def list_knowledge_bases(ctx: AgentRunContext) -> list[KnowledgeBaseResource]: ... + async def retrieve_knowledge(ctx: AgentRunContext, kb_id: str, query: str, ...): ... +``` + +实现可先复用 `LangBotAPIProxy` 底层 action,但 action payload 必须带: + +- `run_id` +- `resource_scope` +- 具体 resource id + +LangBot host 侧必须二次校验。 + +## 12. CLI 和模板 + +更新 `gencomponent`: + +- 支持 `AgentRunner` +- 生成 component manifest 带 `protocol_version/capabilities/permissions` +- 生成 `run()` 示例使用 v1 `AgentRunResult` +- 示例不能再使用 `AgentRunReturn` + +更新 plugin 模板: + +- 可选创建 AgentRunner 组件 +- README 示例展示多 runner 组件 + +## 13. 测试要求 + +SDK 单测: + +- `AgentRunContext` 最小字段 validate +- `AgentRunResult` 每个类型 validate +- legacy helper 行为 +- capabilities / permissions 默认值 +- AgentRunner manifest 多组件 discovery +- `run_agent` 成功流式输出 +- `run_agent` 插件异常 -> `run.failed` +- 旧 `AgentRunReturn` 不再出现在新示例和 docs + +Runtime 集成测试: + +- 单插件多 AgentRunner 组件都能 list +- include_plugins 正确过滤 +- runner 不存在返回 `run.failed` +- context schema 错误返回 `run.failed` + +## 14. 验收标准 + +- SDK 导出的 AgentRunner API 是 v1 context/result。 +- Runtime `LIST_AGENT_RUNNERS` 输出包含 capabilities/permissions/config/protocol。 +- Runtime `RUN_AGENT` 只输出 v1 `AgentRunResult`。 +- 一个插件可以声明多个 AgentRunner。 +- 官方 runner 插件可以只依赖 SDK v1 完成开发。 diff --git a/src/langbot_plugin/api/definition/components/__init__.py b/src/langbot_plugin/api/definition/components/__init__.py index 08e0b41c..3069466b 100644 --- a/src/langbot_plugin/api/definition/components/__init__.py +++ b/src/langbot_plugin/api/definition/components/__init__.py @@ -1,18 +1,15 @@ """Components for LangBot plugins.""" -from langbot_plugin.api.definition.components.base import BaseComponent, PolymorphicComponent +from langbot_plugin.api.definition.components.base import BaseComponent from langbot_plugin.api.definition.components.command.command import Command from langbot_plugin.api.definition.components.tool.tool import Tool from langbot_plugin.api.definition.components.common.event_listener import EventListener -from langbot_plugin.api.definition.components.knowledge_retriever.retriever import KnowledgeRetriever from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner __all__ = [ "BaseComponent", - "PolymorphicComponent", "Command", "Tool", "EventListener", - "KnowledgeRetriever", "AgentRunner", -] +] \ No newline at end of file diff --git a/src/langbot_plugin/api/definition/components/agent_runner/runner.py b/src/langbot_plugin/api/definition/components/agent_runner/runner.py index f3935e33..ba344d2c 100644 --- a/src/langbot_plugin/api/definition/components/agent_runner/runner.py +++ b/src/langbot_plugin/api/definition/components/agent_runner/runner.py @@ -1,85 +1,136 @@ -"""Agent Runner component definition.""" +"""Agent Runner component definition for Protocol v1.""" + from __future__ import annotations import abc -from typing import AsyncGenerator +from typing import Any, AsyncGenerator from langbot_plugin.api.definition.components.base import BaseComponent -from langbot_plugin.api.entities.builtin.agent_runner import context +from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext +from langbot_plugin.api.entities.builtin.agent_runner.result import AgentRunResult +from langbot_plugin.api.entities.builtin.agent_runner.capabilities import ( + AgentRunnerCapabilities, +) +from langbot_plugin.api.entities.builtin.agent_runner.permissions import ( + AgentRunnerPermissions, +) class AgentRunner(BaseComponent): - """Agent Runner component base class. + """Agent Runner component base class for Protocol v1. AgentRunner is responsible for processing user messages and generating responses. It can use LLM models, tools, and knowledge bases to generate intelligent responses. - Unlike Tool or Command components, AgentRunner is not polymorphic - - a plugin can only provide one AgentRunner implementation. + Unlike PoC design, Protocol v1 allows a plugin to have multiple AgentRunner components. + Each runner component exposes its own manifest with name, config, capabilities, and permissions. Example: ```python from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner - from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext, AgentRunReturn + from langbot_plugin.api.entities.builtin.agent_runner import ( + AgentRunContext, + AgentRunResult, + AgentInput, + ) + from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk class MyAgentRunner(AgentRunner): - async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunReturn, None]: - # Use LLM - response = await self.plugin.invoke_llm( - llm_model_uuid=self.config.get('llm_model_uuid'), - messages=ctx.messages + [ - Message(role='user', content=str(ctx.user_message)) - ] + @classmethod + def get_capabilities(cls) -> AgentRunnerCapabilities: + return AgentRunnerCapabilities( + streaming=True, + tool_calling=True, ) - yield AgentRunReturn( - type='finish', - message=response, - finish_reason='stop' - ) + async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: + # Stream response from LLM + for chunk_text in ["Hello", " ", "world"]: + chunk = MessageChunk(role="assistant", content=chunk_text) + yield AgentRunResult.message_delta(chunk) + + # Final message + final_message = Message(role="assistant", content="Hello world") + yield AgentRunResult.run_completed(message=final_message) ``` """ __kind__ = "AgentRunner" + __protocol_version__ = "1" + + @classmethod + def get_capabilities(cls) -> AgentRunnerCapabilities: + """Get default capabilities for this runner. + + Override to declare specific capabilities. + Manifest spec.capabilities takes precedence if declared. + """ + return AgentRunnerCapabilities() + + @classmethod + def get_config_schema(cls) -> list[dict[str, Any]]: + """Get default config schema for this runner. + + Override to declare configuration options. + Manifest spec.config takes precedence if declared. + """ + return [] + + @classmethod + def get_permissions(cls) -> AgentRunnerPermissions: + """Get default permissions for this runner. + + Override to declare required permissions. + Manifest spec.permissions takes precedence if declared. + """ + return AgentRunnerPermissions() @abc.abstractmethod - async def run( - self, ctx: context.AgentRunContext - ) -> AsyncGenerator[context.AgentRunReturn, None]: - """Run the agent to process a user message. + async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: + """Run the agent to process user input. Args: ctx: Agent run context containing: - - query_id: Unique ID for this request - - session: Session information (launcher_type, launcher_id, sender_id) + - run_id: Unique ID for this run + - trigger: What triggered this run + - conversation: Launcher/sender/bot/pipeline info + - event: Event envelope subset (for EBA) + - actor: Who triggered the event + - subject: What the event is about - messages: Historical conversation messages - - user_message: Current user message to process - - use_funcs: Available tools the agent can use - - extra_config: Extra configuration from pipeline config + - input: User input (text, contents, message_chain, attachments) + - resources: Authorized resources (models, tools, KBs, files, storage) + - runtime: Host/environment info (version, query_id, trace_id, deadline) + - config: Runner instance configuration Yields: - AgentRunReturn: Yields progress updates and final result: - - type='chunk': Partial text content (for streaming output) - - type='text': Complete text segment - - type='tool_call': Agent is calling a tool - - type='finish': Final response with complete message + AgentRunResult: Progress and final result events: + - message.delta: Streaming text chunk + - message.completed: Complete message + - tool.call.started: Tool call initiated + - tool.call.completed: Tool call finished + - state.updated: State change notification + - run.completed: Run finished successfully + - run.failed: Run failed with error + - action.requested: Platform action request (future) Example: ```python - async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunReturn, None]: - # Stream response from LLM - async for chunk in self.plugin.invoke_llm_stream(...): - yield AgentRunReturn( - type='chunk', - message_chunk=chunk - ) - - # Indicate completion - yield AgentRunReturn( - type='finish', - message=final_message, - finish_reason='stop' - ) + async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: + # Get input text + user_text = ctx.input.to_text() + + # Use LLM (if authorized) + if ctx.resources.models: + model = ctx.resources.models[0] + # Call LLM via plugin API... + + # Stream response + chunk = MessageChunk(role="assistant", content="Response") + yield AgentRunResult.message_delta(chunk) + + # Complete + yield AgentRunResult.run_completed(finish_reason="stop") ``` """ pass diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py b/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py index e40f42cd..eb94268e 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py @@ -1 +1,59 @@ -"""Agent Runner entities.""" +"""Agent Runner entities for Protocol v1.""" + +from langbot_plugin.api.entities.builtin.agent_runner.capabilities import ( + AgentRunnerCapabilities, +) +from langbot_plugin.api.entities.builtin.agent_runner.permissions import ( + AgentRunnerPermissions, +) +from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.resources import ( + AgentResources, + ModelResource, + ToolResource, + KnowledgeBaseResource, + FileResource, + StorageResource, +) +from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext +from langbot_plugin.api.entities.builtin.agent_runner.event import ( + ConversationContext, + AgentEventContext, + ActorContext, + SubjectContext, +) +from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext +from langbot_plugin.api.entities.builtin.agent_runner.result import ( + AgentRunResult, + AgentRunResultType, +) +from langbot_plugin.api.entities.builtin.agent_runner.legacy import ( + AgentRunReturn, + create_legacy_context, +) + +__all__ = [ + # v1 entities + "AgentRunnerCapabilities", + "AgentRunnerPermissions", + "AgentTrigger", + "AgentInput", + "AgentResources", + "ModelResource", + "ToolResource", + "KnowledgeBaseResource", + "FileResource", + "StorageResource", + "AgentRuntimeContext", + "ConversationContext", + "AgentEventContext", + "ActorContext", + "SubjectContext", + "AgentRunContext", + "AgentRunResult", + "AgentRunResultType", + # Legacy (deprecated) + "AgentRunReturn", + "create_legacy_context", +] diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/capabilities.py b/src/langbot_plugin/api/entities/builtin/agent_runner/capabilities.py new file mode 100644 index 00000000..133078a1 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/capabilities.py @@ -0,0 +1,37 @@ +"""AgentRunner capabilities as defined in Protocol v1.""" + +from __future__ import annotations + +import pydantic + + +class AgentRunnerCapabilities(pydantic.BaseModel): + """Capabilities declared by an AgentRunner component. + + All fields default to False. LangBot uses these to determine + what features the runner may use during execution. + """ + + streaming: bool = False + """Runner may output message.delta events.""" + + tool_calling: bool = False + """Runner needs tool list/detail/call operations.""" + + knowledge_retrieval: bool = False + """Runner needs knowledge base list or retrieval.""" + + multimodal_input: bool = False + """Runner can process image/file/audio non-text input.""" + + event_context: bool = False + """Runner will read ctx.event/actor/subject.""" + + platform_api: bool = False + """Runner may request platform actions (future feature, not executed this phase).""" + + interrupt: bool = False + """Runner supports cancel or interrupt operations.""" + + stateful_session: bool = False + """Runner maintains external conversation/session state.""" diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/context.py b/src/langbot_plugin/api/entities/builtin/agent_runner/context.py index 1fef7008..0f467bac 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/context.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/context.py @@ -1,59 +1,72 @@ -"""Agent Runner context entities.""" +"""Agent run context as defined in Protocol v1.""" + from __future__ import annotations import typing import pydantic -from langbot_plugin.api.entities.builtin.provider import message as provider_message -from langbot_plugin.api.entities.builtin.provider import session as provider_session -from langbot_plugin.api.entities.builtin.resource import tool as resource_tool +from langbot_plugin.api.entities.builtin.provider.message import Message +from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources +from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext +from langbot_plugin.api.entities.builtin.agent_runner.event import ( + ConversationContext, + AgentEventContext, + ActorContext, + SubjectContext, +) class AgentRunContext(pydantic.BaseModel): - """Agent run context passed to AgentRunner.run()""" - - query_id: int - """Query ID for this request""" - - session: provider_session.Session - """Session information""" + """Agent run context passed to AgentRunner.run(). - messages: list[provider_message.Message] - """Historical messages in the conversation""" + Protocol v1 context structure. Contains: + - run_id: unique identifier for this run + - trigger: what triggered this run + - conversation: launcher/sender/bot/pipeline info + - event: event envelope subset (for future EBA) + - actor: who triggered the event + - subject: what the event is about + - messages: historical conversation messages + - input: user input + - resources: authorized resources + - runtime: host/environment info + - config: runner instance configuration + """ - user_message: provider_message.ContentElement - """Current user message""" + run_id: str + """Unique identifier for this run.""" - use_funcs: list[resource_tool.LLMTool] - """Available tools for the agent to use""" + trigger: AgentTrigger + """Trigger information.""" - extra_config: dict[str, typing.Any] = pydantic.Field(default_factory=dict) - """Extra configuration from pipeline config""" - - class Config: - arbitrary_types_allowed = True + conversation: ConversationContext | None = None + """Conversation context.""" + event: AgentEventContext | None = None + """Event context (for EBA).""" -class AgentRunReturn(pydantic.BaseModel): - """Return value from AgentRunner.run()""" + actor: ActorContext | None = None + """Actor context.""" - type: str - """Return type: 'text' | 'chunk' | 'tool_call' | 'finish'""" + subject: SubjectContext | None = None + """Subject context.""" - content: typing.Optional[str] = None - """Text content for 'text' and 'chunk' types""" + messages: list[Message] = pydantic.Field(default_factory=list) + """Historical messages in the conversation.""" - message: typing.Optional[provider_message.Message] = None - """Complete message for 'finish' type""" + input: AgentInput + """User input.""" - message_chunk: typing.Optional[provider_message.MessageChunk] = None - """Message chunk for 'chunk' type""" + resources: AgentResources + """Authorized resources.""" - tool_calls: typing.Optional[list[provider_message.ToolCall]] = None - """Tool calls for 'tool_call' type""" + runtime: AgentRuntimeContext + """Runtime context.""" - finish_reason: typing.Optional[str] = None - """Finish reason for 'finish' type: 'stop' | 'length' | 'tool_calls' | 'error'""" + config: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Runner instance configuration.""" class Config: arbitrary_types_allowed = True diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/event.py b/src/langbot_plugin/api/entities/builtin/agent_runner/event.py new file mode 100644 index 00000000..1e6be84c --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/event.py @@ -0,0 +1,76 @@ +"""Agent event, actor, subject contexts as defined in Protocol v1.""" + +from __future__ import annotations + +import typing +import pydantic + + +class ConversationContext(pydantic.BaseModel): + """Conversation context for an agent run. + + Carries launcher/sender/bot/pipeline/history semantics. + """ + + session_id: str | None = None + """Session identifier.""" + + conversation_id: str | None = None + """Conversation identifier.""" + + launcher_type: str | None = None + """Launcher type (person, group).""" + + launcher_id: str | None = None + """Launcher ID.""" + + sender_id: str | None = None + """Sender ID.""" + + bot_uuid: str | None = None + """Bot UUID.""" + + pipeline_uuid: str | None = None + """Pipeline UUID.""" + + +class AgentEventContext(pydantic.BaseModel): + """Event envelope subset for EBA (Event-Based Architecture) support.""" + + event_type: str | None = None + """Event type.""" + + event_id: str | None = None + """Event ID.""" + + event_timestamp: int | None = None + """Event timestamp.""" + + event_data: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Event payload.""" + + +class ActorContext(pydantic.BaseModel): + """Actor (who triggered the event) context.""" + + actor_type: str | None = None + """Actor type (user, system, plugin).""" + + actor_id: str | None = None + """Actor ID.""" + + actor_name: str | None = None + """Actor display name.""" + + +class SubjectContext(pydantic.BaseModel): + """Subject (what the event is about) context.""" + + subject_type: str | None = None + """Subject type (message, conversation, etc.).""" + + subject_id: str | None = None + """Subject ID.""" + + subject_data: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Subject data.""" diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/input.py b/src/langbot_plugin/api/entities/builtin/agent_runner/input.py new file mode 100644 index 00000000..d82edec9 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/input.py @@ -0,0 +1,42 @@ +"""Agent input entity as defined in Protocol v1.""" + +from __future__ import annotations + +import typing +import pydantic + +from langbot_plugin.api.entities.builtin.provider.message import ContentElement + + +class AgentInput(pydantic.BaseModel): + """Input for an agent run. + + Contains the user's input in multiple formats for convenience. + """ + + text: str | None = None + """Plain text input.""" + + contents: list[ContentElement] = pydantic.Field(default_factory=list) + """Structured content elements (text, images, files, etc.).""" + + message_chain: list[dict[str, typing.Any]] | dict[str, typing.Any] | None = None + """Raw platform message chain (list of message components or dict representation).""" + + attachments: list[dict[str, typing.Any]] = pydantic.Field(default_factory=list) + """File attachments metadata.""" + + def to_text(self) -> str: + """Extract plain text from input. + + Returns text if available, otherwise concatenates text content elements. + """ + if self.text is not None: + return self.text + + text_parts = [] + for content in self.contents: + if content.type == "text" and content.text: + text_parts.append(content.text) + + return " ".join(text_parts) diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/legacy.py b/src/langbot_plugin/api/entities/builtin/agent_runner/legacy.py new file mode 100644 index 00000000..3815399a --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/legacy.py @@ -0,0 +1,262 @@ +"""Legacy helpers for migrating from PoC AgentRunReturn to Protocol v1. + +These helpers are ONLY for official plugin migration. +They should NOT be used as target models for LangBot context construction. + +DEPRECATED: Do not use AgentRunReturn in new plugins. +""" + +from __future__ import annotations + +import typing +import pydantic +import warnings + +from langbot_plugin.api.entities.builtin.provider.message import ( + Message, + MessageChunk, + ContentElement, + ToolCall, +) +from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext +from langbot_plugin.api.entities.builtin.agent_runner.result import AgentRunResult +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.event import ConversationContext +from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger + + +class AgentRunReturn(pydantic.BaseModel): + """DEPRECATED: Legacy return value from PoC AgentRunner. + + Use AgentRunResult instead. + + Migration guide: + - type='chunk', message_chunk -> AgentRunResult.message_delta(chunk) + - type='text', content -> AgentRunResult.message_completed(Message with content) + - type='tool_call', tool_calls -> AgentRunResult.tool_call_started/completed + - type='finish', message, finish_reason -> AgentRunResult.run_completed() + - type='finish', finish_reason='error', content -> AgentRunResult.run_failed() + """ + + type: str + """Return type: 'text' | 'chunk' | 'tool_call' | 'finish' - DEPRECATED.""" + + content: str | None = None + """Text content for 'text' and 'chunk' types - DEPRECATED.""" + + message: Message | None = None + """Complete message for 'finish' type - DEPRECATED.""" + + message_chunk: MessageChunk | None = None + """Message chunk for 'chunk' type - DEPRECATED.""" + + tool_calls: list[ToolCall] | None = None + """Tool calls for 'tool_call' type - DEPRECATED.""" + + finish_reason: str | None = None + """Finish reason for 'finish' type - DEPRECATED.""" + + class Config: + arbitrary_types_allowed = True + + def to_v1_result(self) -> AgentRunResult: + """Convert legacy AgentRunReturn to v1 AgentRunResult. + + WARNING: This is a migration helper only. + """ + warnings.warn( + "AgentRunReturn is deprecated. Use AgentRunResult instead.", + DeprecationWarning, + stacklevel=2, + ) + + if self.type == "chunk": + if self.message_chunk: + return AgentRunResult.message_delta(self.message_chunk) + elif self.content: + # Create a simple chunk from content + chunk = MessageChunk(role="assistant", content=self.content) + return AgentRunResult.message_delta(chunk) + else: + return AgentRunResult.run_failed( + "Empty chunk content", "conversion.error" + ) + + elif self.type == "text": + if self.content: + message = Message(role="assistant", content=self.content) + return AgentRunResult.message_completed(message) + else: + return AgentRunResult.run_failed( + "Empty text content", "conversion.error" + ) + + elif self.type == "tool_call": + if self.tool_calls: + # Only report first tool call for legacy conversion + tc = self.tool_calls[0] + return AgentRunResult.tool_call_started( + tool_call_id=tc.id, + tool_name=tc.function.name, + parameters={"arguments": tc.function.arguments}, + ) + else: + return AgentRunResult.run_failed("Empty tool_calls", "conversion.error") + + elif self.type == "finish": + if self.finish_reason == "error": + return AgentRunResult.run_failed( + error=self.content or "Unknown error", + code="runner.error", + ) + else: + return AgentRunResult.run_completed( + message=self.message, + finish_reason=self.finish_reason or "stop", + ) + + else: + return AgentRunResult.run_failed( + error=f"Unknown legacy type: {self.type}", + code="conversion.error", + ) + + +def create_legacy_context( + query_id: int, + session: typing.Any, + messages: list[Message], + user_message: ContentElement, + use_funcs: list[typing.Any], + extra_config: dict[str, typing.Any], +) -> AgentRunContext: + """Create v1 AgentRunContext from legacy PoC parameters. + + DEPRECATED: LangBot should directly construct AgentRunContext v1. + + Args: + query_id: Legacy query ID + session: Legacy Session object with launcher_type, launcher_id, sender_id + messages: Historical messages + user_message: Current user message as ContentElement + use_funcs: Available tools (LLMTool list) + extra_config: Extra configuration from pipeline + + Returns: + AgentRunContext v1 + """ + warnings.warn( + "create_legacy_context is deprecated. LangBot should construct AgentRunContext directly.", + DeprecationWarning, + stacklevel=2, + ) + + # Extract conversation info from session + launcher_type = None + launcher_id = None + sender_id = None + if hasattr(session, "launcher_type"): + launcher_type = ( + session.launcher_type.value + if hasattr(session.launcher_type, "value") + else str(session.launcher_type) + ) + if hasattr(session, "launcher_id"): + launcher_id = str(session.launcher_id) + if hasattr(session, "sender_id"): + sender_id = str(session.sender_id) if session.sender_id else None + + conversation = ConversationContext( + session_id=None, + conversation_id=None, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=sender_id, + bot_uuid=None, + pipeline_uuid=None, + ) + + # Build input + input_text = None + input_contents: list[ContentElement] = [] + if user_message: + if user_message.type == "text" and user_message.text: + input_text = user_message.text + input_contents = [user_message] + + agent_input = AgentInput( + text=input_text, + contents=input_contents, + message_chain=None, + attachments=[], + ) + + # Build resources (legacy only has tools) + from langbot_plugin.api.entities.builtin.agent_runner.resources import ( + AgentResources, + ToolResource, + StorageResource, + ) + + tool_resources: list[ToolResource] = [] + for func in use_funcs: + if hasattr(func, "function"): + tool_resources.append( + ToolResource( + tool_name=func.function.name + if hasattr(func.function, "name") + else str(func), + tool_type=None, + description=None, + ) + ) + else: + tool_resources.append( + ToolResource( + tool_name=str(func), + tool_type=None, + description=None, + ) + ) + + resources = AgentResources( + models=[], + tools=tool_resources, + knowledge_bases=[], + files=[], + storage=StorageResource(), + platform_capabilities={}, + ) + + trigger = AgentTrigger( + type="message.received", + source="pipeline", + timestamp=None, + ) + + from langbot_plugin.api.entities.builtin.agent_runner.runtime import ( + AgentRuntimeContext, + ) + + runtime = AgentRuntimeContext( + langbot_version=None, + sdk_protocol_version="1", + query_id=query_id, + trace_id=None, + deadline_at=None, + metadata={}, + ) + + return AgentRunContext( + run_id=f"run_{query_id}", + trigger=trigger, + conversation=conversation, + event=None, + actor=None, + subject=None, + messages=messages, + input=agent_input, + resources=resources, + runtime=runtime, + config=extra_config, + ) diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/permissions.py b/src/langbot_plugin/api/entities/builtin/agent_runner/permissions.py new file mode 100644 index 00000000..6da2a2fc --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/permissions.py @@ -0,0 +1,44 @@ +"""AgentRunner permissions as defined in Protocol v1.""" + +from __future__ import annotations + +import typing +import pydantic + + +class AgentRunnerPermissions(pydantic.BaseModel): + """Permissions requested by an AgentRunner component. + + All fields default to empty list. These represent the upper limit + of what a runner can request. LangBot execution must further filter + based on Pipeline/Bot binding scope and user configuration to + produce ctx.resources. + """ + + models: list[typing.Literal["list", "invoke", "stream", "embedding"]] = ( + pydantic.Field(default_factory=list) + ) + """Model operations allowed.""" + + tools: list[typing.Literal["list", "detail", "call"]] = pydantic.Field( + default_factory=list + ) + """Tool operations allowed.""" + + knowledge_bases: list[typing.Literal["list", "retrieve"]] = pydantic.Field( + default_factory=list + ) + """Knowledge base operations allowed.""" + + storage: list[typing.Literal["plugin", "workspace"]] = pydantic.Field( + default_factory=list + ) + """Storage scopes allowed.""" + + files: list[typing.Literal["config", "knowledge"]] = pydantic.Field( + default_factory=list + ) + """File access scopes allowed.""" + + platform_api: list[str] = pydantic.Field(default_factory=list) + """Platform API actions allowed (future feature).""" diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/resources.py b/src/langbot_plugin/api/entities/builtin/agent_runner/resources.py new file mode 100644 index 00000000..9608d620 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/resources.py @@ -0,0 +1,97 @@ +"""Agent resources entity as defined in Protocol v1.""" + +from __future__ import annotations + +import typing +import pydantic + + +class ModelResource(pydantic.BaseModel): + """Model resource available to the agent.""" + + model_id: str + """Model identifier.""" + + model_type: str | None = None + """Model type (chat, embedding, etc.).""" + + provider: str | None = None + """Model provider name.""" + + +class ToolResource(pydantic.BaseModel): + """Tool resource available to the agent.""" + + tool_name: str + """Tool name.""" + + tool_type: str | None = None + """Tool type.""" + + description: str | None = None + """Tool description.""" + + +class KnowledgeBaseResource(pydantic.BaseModel): + """Knowledge base resource available to the agent.""" + + kb_id: str + """Knowledge base identifier.""" + + kb_name: str | None = None + """Knowledge base display name.""" + + kb_type: str | None = None + """Knowledge base type.""" + + +class FileResource(pydantic.BaseModel): + """File resource available to the agent.""" + + file_id: str + """File identifier.""" + + file_name: str | None = None + """File name.""" + + mime_type: str | None = None + """File MIME type.""" + + source: str | None = None + """File source (config, knowledge, etc.).""" + + +class StorageResource(pydantic.BaseModel): + """Storage resources available to the agent.""" + + plugin_storage: bool = False + """Whether plugin storage is accessible.""" + + workspace_storage: bool = False + """Whether workspace storage is accessible.""" + + +class AgentResources(pydantic.BaseModel): + """Resources available to an agent run. + + Represents what LangBot has authorized for this run. + LangBot host must still validate actual calls. + """ + + models: list[ModelResource] = pydantic.Field(default_factory=list) + """Available models.""" + + tools: list[ToolResource] = pydantic.Field(default_factory=list) + """Available tools.""" + + knowledge_bases: list[KnowledgeBaseResource] = pydantic.Field(default_factory=list) + """Available knowledge bases.""" + + files: list[FileResource] = pydantic.Field(default_factory=list) + """Available files.""" + + storage: StorageResource = pydantic.Field(default_factory=StorageResource) + """Storage access.""" + + platform_capabilities: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Platform capabilities available.""" diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/result.py b/src/langbot_plugin/api/entities/builtin/agent_runner/result.py new file mode 100644 index 00000000..1969aa52 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/result.py @@ -0,0 +1,163 @@ +"""AgentRunResult as defined in Protocol v1.""" + +from __future__ import annotations + +import typing +import pydantic +import enum + +from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk + + +class AgentRunResultType(str, enum.Enum): + """Type of AgentRunResult event.""" + + MESSAGE_DELTA = "message.delta" + MESSAGE_COMPLETED = "message.completed" + TOOL_CALL_STARTED = "tool.call.started" + TOOL_CALL_COMPLETED = "tool.call.completed" + STATE_UPDATED = "state.updated" + RUN_COMPLETED = "run.completed" + RUN_FAILED = "run.failed" + ACTION_REQUESTED = "action.requested" + + +class AgentRunResult(pydantic.BaseModel): + """Result event from AgentRunner.run(). + + Each yield from the runner's run() method produces one AgentRunResult. + LangBot maps these to appropriate pipeline events. + """ + + type: AgentRunResultType + """Result type.""" + + data: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Result data.""" + + @classmethod + def message_delta(cls, chunk: MessageChunk) -> "AgentRunResult": + """Create a message.delta result. + + LangBot maps this to MessageChunk for streaming output. + """ + return cls( + type=AgentRunResultType.MESSAGE_DELTA, + data={"chunk": chunk.model_dump(mode="json")}, + ) + + @classmethod + def message_completed(cls, message: Message) -> "AgentRunResult": + """Create a message.completed result. + + LangBot maps this to a complete Message. + """ + return cls( + type=AgentRunResultType.MESSAGE_COMPLETED, + data={"message": message.model_dump(mode="json")}, + ) + + @classmethod + def tool_call_started( + cls, + tool_call_id: str, + tool_name: str, + parameters: dict[str, typing.Any], + ) -> "AgentRunResult": + """Create a tool.call.started result. + + LangBot records this for telemetry/debug. + """ + return cls( + type=AgentRunResultType.TOOL_CALL_STARTED, + data={ + "tool_call_id": tool_call_id, + "tool_name": tool_name, + "parameters": parameters, + }, + ) + + @classmethod + def tool_call_completed( + cls, + tool_call_id: str, + tool_name: str, + result: dict[str, typing.Any] | None = None, + error: str | None = None, + ) -> "AgentRunResult": + """Create a tool.call.completed result. + + LangBot records this for telemetry/debug. + """ + return cls( + type=AgentRunResultType.TOOL_CALL_COMPLETED, + data={ + "tool_call_id": tool_call_id, + "tool_name": tool_name, + "result": result, + "error": error, + }, + ) + + @classmethod + def state_updated(cls, key: str, value: typing.Any) -> "AgentRunResult": + """Create a state.updated result. + + LangBot records this but does not auto-persist. + Official plugins should use plugin storage instead. + """ + return cls( + type=AgentRunResultType.STATE_UPDATED, + data={"key": key, "value": value}, + ) + + @classmethod + def run_completed( + cls, + message: Message | None = None, + finish_reason: str = "stop", + ) -> "AgentRunResult": + """Create a run.completed result. + + If message is provided, LangBot can map it to final Message. + If message.completed was already output, message can be None. + """ + data: dict[str, typing.Any] = {"finish_reason": finish_reason} + if message is not None: + data["message"] = message.model_dump(mode="json") + return cls(type=AgentRunResultType.RUN_COMPLETED, data=data) + + @classmethod + def run_failed( + cls, + error: str, + code: str | None = None, + retryable: bool = False, + ) -> "AgentRunResult": + """Create a run.failed result. + + LangBot returns user-friendly error message per pipeline error strategy. + """ + return cls( + type=AgentRunResultType.RUN_FAILED, + data={ + "error": error, + "code": code or "runner.error", + "retryable": retryable, + }, + ) + + @classmethod + def action_requested( + cls, + action: str, + parameters: dict[str, typing.Any], + ) -> "AgentRunResult": + """Create an action.requested result. + + This phase only logs to telemetry, actual execution waits for EBA. + """ + return cls( + type=AgentRunResultType.ACTION_REQUESTED, + data={"action": action, "parameters": parameters}, + ) diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/runtime.py b/src/langbot_plugin/api/entities/builtin/agent_runner/runtime.py new file mode 100644 index 00000000..747f6787 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/runtime.py @@ -0,0 +1,31 @@ +"""Agent runtime context as defined in Protocol v1.""" + +from __future__ import annotations + +import typing +import pydantic + + +class AgentRuntimeContext(pydantic.BaseModel): + """Runtime context for an agent run. + + Provides host/environment information for the agent. + """ + + langbot_version: str | None = None + """LangBot host version.""" + + sdk_protocol_version: str = "1" + """SDK protocol version.""" + + query_id: int | None = None + """Query ID (for legacy compatibility).""" + + trace_id: str | None = None + """Trace ID for observability.""" + + deadline_at: int | None = None + """Deadline timestamp (epoch seconds) for timeout.""" + + metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Additional runtime metadata.""" diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/trigger.py b/src/langbot_plugin/api/entities/builtin/agent_runner/trigger.py new file mode 100644 index 00000000..e9f72314 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/trigger.py @@ -0,0 +1,22 @@ +"""Agent trigger context as defined in Protocol v1.""" + +from __future__ import annotations + +import typing +import pydantic + + +class AgentTrigger(pydantic.BaseModel): + """Trigger information for an agent run. + + Indicates what triggered this agent execution. + """ + + type: str + """Trigger type, e.g., 'message.received'.""" + + source: typing.Literal["pipeline", "event_router"] = "pipeline" + """Source of the trigger.""" + + timestamp: int | None = None + """Trigger timestamp (epoch seconds).""" diff --git a/src/langbot_plugin/api/entities/builtin/provider/message.py b/src/langbot_plugin/api/entities/builtin/provider/message.py index 69d1825c..6b8e55d9 100644 --- a/src/langbot_plugin/api/entities/builtin/provider/message.py +++ b/src/langbot_plugin/api/entities/builtin/provider/message.py @@ -95,6 +95,9 @@ class Message(pydantic.BaseModel): tool_call_id: typing.Optional[str] = None + resp_message_id: typing.Optional[str] = None + """Response message ID for tracking""" + def readable_str(self) -> str: if self.content is not None: return ( diff --git a/src/langbot_plugin/assets/templates/components/agent_runner/__init__.py b/src/langbot_plugin/assets/templates/components/agent_runner/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/langbot_plugin/assets/templates/components/agent_runner/{runner_name}.py.example b/src/langbot_plugin/assets/templates/components/agent_runner/{runner_name}.py.example new file mode 100644 index 00000000..dcab3ad3 --- /dev/null +++ b/src/langbot_plugin/assets/templates/components/agent_runner/{runner_name}.py.example @@ -0,0 +1,83 @@ +"""Agent Runner implementation for Protocol v1.""" +from __future__ import annotations + +from typing import AsyncGenerator + +from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner +from langbot_plugin.api.entities.builtin.agent_runner import ( + AgentRunContext, + AgentRunResult, + AgentRunnerCapabilities, + AgentRunnerPermissions, +) +from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk + + +class {{ runner_attr }}(AgentRunner): + """{{ runner_description }}""" + + @classmethod + def get_capabilities(cls) -> AgentRunnerCapabilities: + """Declare capabilities for this runner. + + Override to enable features like streaming, tool calling, etc. + """ + return AgentRunnerCapabilities( + streaming=False, + tool_calling=False, + knowledge_retrieval=False, + multimodal_input=False, + event_context=False, + platform_api=False, + interrupt=False, + stateful_session=False, + ) + + @classmethod + def get_permissions(cls) -> AgentRunnerPermissions: + """Declare permissions required by this runner. + + Override to request access to models, tools, knowledge bases, etc. + """ + return AgentRunnerPermissions( + models=[], # ["list", "invoke", "stream", "embedding"] + tools=[], # ["list", "detail", "call"] + knowledge_bases=[], # ["list", "retrieve"] + storage=[], # ["plugin", "workspace"] + files=[], # ["config", "knowledge"] + platform_api=[], + ) + + async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: + """Process user input and generate response. + + Args: + ctx: Agent run context containing: + - run_id: Unique ID for this run + - trigger: Trigger information + - conversation: Launcher/sender/bot/pipeline info + - messages: Historical messages + - input: User input (use ctx.input.to_text() for plain text) + - resources: Authorized resources + - runtime: Host/environment info + - config: Runner configuration + + Yields: + AgentRunResult events (message.delta, message.completed, run.completed, etc.) + """ + # Example: get user input text + user_text = ctx.input.to_text() + + # Example: return a simple response + # For streaming, use AgentRunResult.message_delta(chunk) + # For final response, use AgentRunResult.run_completed(message) + + final_message = Message( + role="assistant", + content=f"Received: {user_text}", + ) + + yield AgentRunResult.run_completed( + message=final_message, + finish_reason="stop", + ) \ No newline at end of file diff --git a/src/langbot_plugin/assets/templates/components/agent_runner/{runner_name}.yaml.example b/src/langbot_plugin/assets/templates/components/agent_runner/{runner_name}.yaml.example new file mode 100644 index 00000000..7596573a --- /dev/null +++ b/src/langbot_plugin/assets/templates/components/agent_runner/{runner_name}.yaml.example @@ -0,0 +1,33 @@ +apiVersion: langbot/v1 +kind: AgentRunner +metadata: + name: {{ runner_name }} + label: + en_US: {{ runner_label }} + zh_Hans: {{ runner_label }} + description: + en_US: {{ runner_description }} + zh_Hans: {{ runner_description }} +spec: + protocol_version: "1" + config: [] + capabilities: + streaming: false + tool_calling: false + knowledge_retrieval: false + multimodal_input: false + event_context: false + platform_api: false + interrupt: false + stateful_session: false + permissions: + models: [] + tools: [] + knowledge_bases: [] + storage: [] + files: [] + platform_api: [] +execution: + python: + path: ./{{ runner_name }}.py + attr: {{ runner_attr }} \ No newline at end of file diff --git a/src/langbot_plugin/cli/gen/renderer.py b/src/langbot_plugin/cli/gen/renderer.py index 3d2a0eb2..ab01ea83 100644 --- a/src/langbot_plugin/cli/gen/renderer.py +++ b/src/langbot_plugin/cli/gen/renderer.py @@ -146,6 +146,22 @@ def page_component_input_post_process(values: dict[str, Any]) -> dict[str, Any]: return result +def agent_runner_component_input_post_process(values: dict[str, Any]) -> dict[str, Any]: + result = { + "runner_name": values["runner_name"], + "runner_label": values["runner_name"], + "runner_description": values["runner_description"], + "runner_attr": values["runner_name"], + } + + python_attr_valid_name = "".join( + word.capitalize() for word in values["runner_name"].split("_") + ) + result["runner_label"] = python_attr_valid_name + result["runner_attr"] = python_attr_valid_name + return result + + component_types = [ ComponentType( type_name="EventListener", @@ -388,4 +404,53 @@ def page_component_input_post_process(values: dict[str, Any]) -> dict[str, Any]: ], input_post_process=page_component_input_post_process, ), + ComponentType( + type_name="AgentRunner", + target_dir="components/agent_runner", + template_files=[ + "{runner_name}.yaml", + "{runner_name}.py", + ], + form_fields=[ + { + "name": "runner_name", + "label": { + "en_US": "Agent Runner name", + "zh_Hans": "Agent Runner 名称", + "zh_Hant": "Agent Runner 名稱", + "ja_JP": "Agent Runner名", + "th_TH": "ชื่อ Agent Runner", + "vi_VN": "Tên Agent Runner", + "es_ES": "Nombre de Agent Runner", + }, + "required": True, + "format": { + "regexp": NUMBER_LOWER_UNDERSCORE_REGEXP, + "error": { + "en_US": "Invalid Agent Runner name, please use a valid name, which only contains lowercase letters, numbers, underscores and hyphens, and start with a letter.", + "zh_Hans": "无效的 Agent Runner 名称,请使用一个有效的名称,只能包含小写字母、数字、下划线和连字符,且以字母开头。", + "zh_Hant": "無效的 Agent Runner 名稱,請使用一個有效的名稱,只能包含小寫字母、數字、下劃線和連字符,且以字母開頭。", + "ja_JP": "無効なAgent Runner名です。有効な名前を使用してください。小文字、数字、アンダースコア、ハイフンのみを使用し、先頭は文字でなければなりません。", + "th_TH": "ชื่อ Agent Runner ไม่ถูกต้อง กรุณาใช้ชื่อที่ถูกต้อง ซึ่งประกอบด้วยตัวอักษรพิมพ์เล็ก ตัวเลข ขีดล่าง และขีดกลาง และขึ้นต้นด้วยตัวอักษร", + "vi_VN": "Tên Agent Runner không hợp lệ, vui lòng sử dụng tên hợp lệ, chỉ chứa chữ thường, số, dấu gạch dưới và dấu gạch ngang, bắt đầu bằng chữ cái.", + "es_ES": "Nombre de Agent Runner no válido, por favor use un nombre válido que solo contenga letras minúsculas, números, guiones bajos y guiones, comenzando con una letra.", + }, + }, + }, + { + "name": "runner_description", + "label": { + "en_US": "Agent Runner description", + "zh_Hans": "Agent Runner 描述", + "zh_Hant": "Agent Runner 描述", + "ja_JP": "Agent Runnerの説明", + "th_TH": "คำอธิบาย Agent Runner", + "vi_VN": "Mô tả Agent Runner", + "es_ES": "Descripción de Agent Runner", + }, + "required": True, + }, + ], + input_post_process=agent_runner_component_input_post_process, + ), ] diff --git a/src/langbot_plugin/cli/run/controller.py b/src/langbot_plugin/cli/run/controller.py index f138b23a..c63f5d3c 100644 --- a/src/langbot_plugin/cli/run/controller.py +++ b/src/langbot_plugin/cli/run/controller.py @@ -30,6 +30,7 @@ ) from langbot_plugin.api.definition.components.page import Page from langbot_plugin.api.definition.components.parser.parser import Parser +from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner from langbot_plugin.entities.io.errors import ConnectionClosedError from langbot_plugin.cli.run.hotreload import HotReloader, reload_plugin_modules @@ -269,11 +270,20 @@ async def initialize(self, plugin_settings: dict[str, typing.Any]) -> None: KnowledgeEngine, Parser, Page, + AgentRunner, ] for component_cls in preinitialize_component_classes: for component_container in self.plugin_container.components: + logger.debug( + f"Checking component {component_container.manifest.metadata.name}: " + f"kind={component_container.manifest.kind}, expected={component_cls.__kind__}" + ) if component_container.manifest.kind == component_cls.__kind__: + logger.info( + f"Initializing {component_cls.__kind__} component: " + f"{component_container.manifest.metadata.name}" + ) component_impl_cls = ( component_container.manifest.get_python_component_class() ) @@ -283,6 +293,10 @@ async def initialize(self, plugin_settings: dict[str, typing.Any]) -> None: self.plugin_container.plugin_instance ) await component_container.component_instance.initialize() + logger.info( + f"Component {component_container.manifest.metadata.name} initialized, " + f"instance type: {type(component_container.component_instance).__name__}" + ) logger.info( f"Plugin {self.plugin_container.manifest.metadata.author}/{self.plugin_container.manifest.metadata.name} initialized" diff --git a/src/langbot_plugin/cli/run/handler.py b/src/langbot_plugin/cli/run/handler.py index 6060ce1a..ac5ea1e2 100644 --- a/src/langbot_plugin/cli/run/handler.py +++ b/src/langbot_plugin/cli/run/handler.py @@ -313,6 +313,65 @@ async def execute_command( f"Command {command_context.command} not found" ) + @self.action(RuntimeToPluginAction.RUN_AGENT) + async def run_agent( + data: dict[str, typing.Any], + ) -> typing.AsyncGenerator[ActionResponse, None]: + """Run an AgentRunner component.""" + from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner + from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext + from langbot_plugin.api.entities.builtin.agent_runner.result import AgentRunResult + + runner_name = data["runner_name"] + context_data = data["context"] + + # Validate context + try: + run_context = AgentRunContext.model_validate(context_data) + except Exception as e: + yield ActionResponse.error(f"Context validation failed: {e}") + return + + # Find the AgentRunner component + runner_component = None + for component in self.plugin_container.components: + if component.manifest.kind == AgentRunner.__kind__: + if component.manifest.metadata.name == runner_name: + runner_component = component + break + + if runner_component is None: + yield ActionResponse.error( + f"AgentRunner {runner_name} not found", + data={"type": "run.failed", "data": {"error": f"AgentRunner {runner_name} not found", "code": "runner.not_found"}} + ) + return + + # Check if initialized + if isinstance(runner_component.component_instance, NoneComponent): + yield ActionResponse.error( + f"AgentRunner {runner_name} not initialized", + data={"type": "run.failed", "data": {"error": f"AgentRunner {runner_name} not initialized", "code": "runner.not_initialized"}} + ) + return + + runner_instance = runner_component.component_instance + assert isinstance(runner_instance, AgentRunner) + + # Run the agent and stream results + try: + async for result in runner_instance.run(run_context): + yield ActionResponse.success(result.model_dump(mode="json")) + except Exception as e: + import traceback + traceback.print_exc() + yield ActionResponse.success( + AgentRunResult.run_failed( + error=f"Error running agent: {e}", + code="runner.exception", + ).model_dump(mode="json") + ) + @self.action(RuntimeToPluginAction.RETRIEVE_KNOWLEDGE) async def retrieve_knowledge(data: dict[str, typing.Any]) -> ActionResponse: """Retrieve knowledge using a KnowledgeEngine instance.""" diff --git a/src/langbot_plugin/entities/io/actions/enums.py b/src/langbot_plugin/entities/io/actions/enums.py index fe82a352..d6a1a21c 100644 --- a/src/langbot_plugin/entities/io/actions/enums.py +++ b/src/langbot_plugin/entities/io/actions/enums.py @@ -92,6 +92,9 @@ class RuntimeToPluginAction(ActionType): EXECUTE_COMMAND = "execute_command" SHUTDOWN = "shutdown" + # AgentRunner actions + RUN_AGENT = "run_agent" + RETRIEVE_KNOWLEDGE = "retrieve_knowledge" INGEST_DOCUMENT = "ingest_document" DELETE_DOCUMENT = "delete_document" @@ -126,10 +129,6 @@ class LangBotToRuntimeAction(ActionType): LIST_AGENT_RUNNERS = "list_agent_runners" RUN_AGENT = "run_agent" - # Knowledge Retriever actions - LIST_KNOWLEDGE_RETRIEVERS = "list_knowledge_retrievers" - RETRIEVE_KNOWLEDGE = "retrieve_knowledge" - # Knowledge Engine actions (LangBot -> Runtime -> Plugin) LIST_KNOWLEDGE_ENGINES = "list_knowledge_engines" RAG_INGEST_DOCUMENT = "rag_ingest_document" diff --git a/src/langbot_plugin/runtime/io/handlers/control.py b/src/langbot_plugin/runtime/io/handlers/control.py index ad50cc03..a35730b7 100644 --- a/src/langbot_plugin/runtime/io/handlers/control.py +++ b/src/langbot_plugin/runtime/io/handlers/control.py @@ -116,7 +116,13 @@ async def get_plugin_assets_file( async def page_api( data: dict[str, Any], ) -> handler.ActionResponse: - for field in ("plugin_author", "plugin_name", "page_id", "endpoint", "method"): + for field in ( + "plugin_author", + "plugin_name", + "page_id", + "endpoint", + "method", + ): if field not in data: return handler.ActionResponse.success( {"data": None, "error": f"Missing required field: {field}"} @@ -259,7 +265,9 @@ async def list_agent_runners(data: dict[str, Any]) -> handler.ActionResponse: return handler.ActionResponse.success({"runners": runners}) @self.action(LangBotToRuntimeAction.RUN_AGENT) - async def run_agent(data: dict[str, Any]) -> AsyncGenerator[handler.ActionResponse, None]: + async def run_agent( + data: dict[str, Any], + ) -> AsyncGenerator[handler.ActionResponse, None]: plugin_author = data["plugin_author"] plugin_name = data["plugin_name"] runner_name = data["runner_name"] @@ -270,25 +278,6 @@ async def run_agent(data: dict[str, Any]) -> AsyncGenerator[handler.ActionRespon ): yield handler.ActionResponse.success(result) - # KnowledgeRetriever actions - @self.action(LangBotToRuntimeAction.LIST_KNOWLEDGE_RETRIEVERS) - async def list_knowledge_retrievers(data: dict[str, Any]) -> handler.ActionResponse: - include_plugins = data.get("include_plugins") - retrievers = await self.context.plugin_mgr.list_knowledge_retrievers(include_plugins) - return handler.ActionResponse.success({"retrievers": retrievers}) - - @self.action(LangBotToRuntimeAction.RETRIEVE_KNOWLEDGE) - async def retrieve_knowledge(data: dict[str, Any]) -> handler.ActionResponse: - plugin_author = data["plugin_author"] - plugin_name = data["plugin_name"] - retriever_name = data["retriever_name"] - retrieval_context = data["retrieval_context"] - - resp = await self.context.plugin_mgr.retrieve_knowledge( - plugin_author, plugin_name, retriever_name, retrieval_context - ) - return handler.ActionResponse.success(resp) - @self.action(LangBotToRuntimeAction.GET_DEBUG_INFO) async def get_debug_info(data: dict[str, Any]) -> handler.ActionResponse: """Get debug information including debug key and WS URL.""" diff --git a/src/langbot_plugin/runtime/plugin/mgr.py b/src/langbot_plugin/runtime/plugin/mgr.py index efca03b3..6c274ff4 100644 --- a/src/langbot_plugin/runtime/plugin/mgr.py +++ b/src/langbot_plugin/runtime/plugin/mgr.py @@ -31,6 +31,7 @@ from langbot_plugin.api.definition.components.parser.parser import Parser from langbot_plugin.entities.io.actions.enums import ( RuntimeToLangBotAction, + RuntimeToPluginAction, ) from langbot_plugin.api.entities.builtin.command.context import ( ExecuteContext, @@ -427,7 +428,9 @@ async def register_plugin( # refresh plugin container from plugin (components may have changed) plugin_container_data = await handler.get_plugin_container() - refreshed = runtime_plugin_container.PluginContainer.from_dict(plugin_container_data) + refreshed = runtime_plugin_container.PluginContainer.from_dict( + plugin_container_data + ) plugin_container.components = refreshed.components plugin_container.manifest = refreshed.manifest plugin_container.status = refreshed.status @@ -802,12 +805,24 @@ async def execute_command( break - # AgentRunner methods + # AgentRunner methods (Protocol v1) async def list_agent_runners( self, include_plugins: list[str] | None = None ) -> list[dict[str, typing.Any]]: - """List all available AgentRunner components from plugins.""" - from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner + """List all available AgentRunner components from plugins. + + Returns v1 protocol format with capabilities, permissions, and config. + A plugin can have multiple AgentRunner components. + """ + from langbot_plugin.api.definition.components.agent_runner.runner import ( + AgentRunner, + ) + from langbot_plugin.api.entities.builtin.agent_runner.capabilities import ( + AgentRunnerCapabilities, + ) + from langbot_plugin.api.entities.builtin.agent_runner.permissions import ( + AgentRunnerPermissions, + ) runners: list[dict[str, typing.Any]] = [] @@ -822,13 +837,34 @@ async def list_agent_runners( for component in plugin.components: if component.manifest.kind == AgentRunner.__kind__: + # Get spec from manifest, with defaults + spec = component.manifest.spec or {} + + # Parse capabilities from manifest or use class defaults + capabilities_data = spec.get("capabilities", {}) + capabilities = AgentRunnerCapabilities(**capabilities_data) + + # Parse permissions from manifest or use class defaults + permissions_data = spec.get("permissions", {}) + permissions = AgentRunnerPermissions(**permissions_data) + + # Get config schema + config_schema = spec.get("config", []) + + # Get protocol version + protocol_version = spec.get("protocol_version", "1") + runners.append( { "plugin_author": plugin.manifest.metadata.author, "plugin_name": plugin.manifest.metadata.name, "runner_name": component.manifest.metadata.name, "runner_description": component.manifest.metadata.description, - "manifest": component.manifest.model_dump(), + "manifest": component.manifest.manifest, # raw manifest dict + "protocol_version": protocol_version, + "capabilities": capabilities.model_dump(), + "permissions": permissions.model_dump(), + "config": config_schema, } ) @@ -841,10 +877,16 @@ async def run_agent( runner_name: str, context: dict[str, typing.Any], ) -> typing.AsyncGenerator[dict[str, typing.Any], None]: - """Run an AgentRunner component.""" - from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner - from langbot_plugin.api.entities.builtin.agent_runner.context import ( - AgentRunContext, + """Run an AgentRunner component with Protocol v1. + + Forwards the RUN_AGENT action to the plugin process and streams results back. + All errors are converted to run.failed events. + """ + from langbot_plugin.api.definition.components.agent_runner.runner import ( + AgentRunner, + ) + from langbot_plugin.api.entities.builtin.agent_runner.result import ( + AgentRunResult, ) # Find the plugin @@ -858,14 +900,13 @@ async def run_agent( break if target_plugin is None: - yield { - "type": "finish", - "finish_reason": "error", - "content": f"Plugin {plugin_author}/{plugin_name} not found", - } + yield AgentRunResult.run_failed( + error=f"Plugin {plugin_author}/{plugin_name} not found", + code="runner.plugin_not_found", + ).model_dump(mode="json") return - # Find the component + # Find the component (supports multiple runners per plugin) target_component = None for component in target_plugin.components: if ( @@ -876,90 +917,43 @@ async def run_agent( break if target_component is None: - yield { - "type": "finish", - "finish_reason": "error", - "content": f"AgentRunner {runner_name} not found in plugin {plugin_author}/{plugin_name}", - } + yield AgentRunResult.run_failed( + error=f"AgentRunner {runner_name} not found in plugin {plugin_author}/{plugin_name}", + code="runner.not_found", + ).model_dump(mode="json") return - # Get the runner instance - runner_instance = target_component.python_component_inst - - if runner_instance is None: - yield { - "type": "finish", - "finish_reason": "error", - "content": f"AgentRunner {runner_name} not initialized", - } + # Check if plugin handler exists for forwarding + if target_plugin._runtime_plugin_handler is None: + yield AgentRunResult.run_failed( + error=f"Plugin {plugin_author}/{plugin_name} has no runtime handler", + code="runner.handler_not_found", + ).model_dump(mode="json") return - # Parse context - run_context = AgentRunContext.model_validate(context) - - # Run the agent + # Forward RUN_AGENT action to the plugin process and stream results try: - async for result in runner_instance.run(run_context): - yield result.model_dump() + gen = target_plugin._runtime_plugin_handler.call_action_generator( + RuntimeToPluginAction.RUN_AGENT, + { + "runner_name": runner_name, + "context": context, + }, + timeout=300, + ) + + # call_action_generator yields response.data directly on success, + # or raises ActionCallError on failure + async for result_data in gen: + yield result_data + except Exception as e: import traceback - traceback.print_exc() - yield { - "type": "finish", - "finish_reason": "error", - "content": f"Error running agent: {e}", - } - - # KnowledgeRetriever methods - async def list_knowledge_retrievers( - self, include_plugins: list[str] | None = None - ) -> list[dict[str, typing.Any]]: - """List all available KnowledgeRetriever components from plugins.""" - retrievers: list[dict[str, typing.Any]] = [] - - for plugin in self.plugins: - if include_plugins is not None: - plugin_id = ( - f"{plugin.manifest.metadata.author}/{plugin.manifest.metadata.name}" - ) - if plugin_id not in include_plugins: - continue - - for component in plugin.components: - if component.manifest.kind == "KnowledgeRetriever": - retrievers.append( - { - "plugin_author": plugin.manifest.metadata.author, - "plugin_name": plugin.manifest.metadata.name, - "retriever_name": component.manifest.metadata.name, - "retriever_description": component.manifest.metadata.description, - "manifest": component.manifest.model_dump(), - } - ) - - return retrievers - - async def retrieve_knowledge( - self, - plugin_author: str, - plugin_name: str, - retriever_name: str, - retrieval_context: dict[str, typing.Any], - ) -> dict[str, typing.Any]: - """Retrieve knowledge using a KnowledgeEngine instance.""" - target_plugin = self.find_plugin(plugin_author, plugin_name) - - if target_plugin is None: - raise ValueError(f"Plugin {plugin_author}/{plugin_name} not found") - - if target_plugin._runtime_plugin_handler is None: - raise ValueError(f"Plugin {plugin_author}/{plugin_name} is not connected") - - resp = await target_plugin._runtime_plugin_handler.retrieve_knowledge( - retriever_name, retrieval_context - ) - return resp + yield AgentRunResult.run_failed( + error=f"Error forwarding to plugin: {e}", + code="runner.forward_exception", + ).model_dump(mode="json") # ================= Knowledge Engine Methods ================= diff --git a/tests/api/definition/components/test_imports.py b/tests/api/definition/components/test_imports.py new file mode 100644 index 00000000..9032c303 --- /dev/null +++ b/tests/api/definition/components/test_imports.py @@ -0,0 +1,62 @@ +"""Regression test for components module imports. + +This test verifies that all exported components can be imported successfully. +""" + +from __future__ import annotations + + +def test_components_import_success(): + """Test that all exported components can be imported without ImportError. + + This is a regression test to ensure we don't accidentally export + symbols that don't exist in the codebase. + """ + # Should succeed without ImportError + from langbot_plugin.api.definition.components import ( + BaseComponent, + Command, + Tool, + EventListener, + AgentRunner, + ) + + # Verify they are the expected classes + assert BaseComponent.__name__ == "BaseComponent" + assert Command.__kind__ == "Command" + assert Tool.__kind__ == "Tool" + assert EventListener.__kind__ == "EventListener" + assert AgentRunner.__kind__ == "AgentRunner" + + +def test_components_all_exports_exist(): + """Test that __all__ only contains symbols that can be imported.""" + import langbot_plugin.api.definition.components as components + + for name in components.__all__: + # Each exported name must be accessible + assert hasattr(components, name), f"{name} in __all__ but not importable" + + +def test_no_polymorphic_component_export(): + """Verify PolymorphicComponent is NOT exported (does not exist in codebase). + + This prevents accidental reintroduction of non-existent symbols. + """ + import langbot_plugin.api.definition.components as components + + # PolymorphicComponent should NOT be in __all__ or importable + assert "PolymorphicComponent" not in components.__all__ + assert not hasattr(components, "PolymorphicComponent") + + +def test_no_knowledge_retriever_export(): + """Verify KnowledgeRetriever is NOT exported (does not exist in codebase). + + KnowledgeEngine exists instead. KnowledgeRetriever is historical. + """ + import langbot_plugin.api.definition.components as components + + # KnowledgeRetriever should NOT be in __all__ or importable + assert "KnowledgeRetriever" not in components.__all__ + assert not hasattr(components, "KnowledgeRetriever") diff --git a/tests/api/entities/builtin/agent_runner/test_context_result.py b/tests/api/entities/builtin/agent_runner/test_context_result.py new file mode 100644 index 00000000..64c38bb4 --- /dev/null +++ b/tests/api/entities/builtin/agent_runner/test_context_result.py @@ -0,0 +1,355 @@ +"""Tests for AgentRunContext and AgentRunResult Protocol v1.""" + +from __future__ import annotations + +import pytest +import pydantic + +from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext +from langbot_plugin.api.entities.builtin.agent_runner.result import ( + AgentRunResult, + AgentRunResultType, +) +from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.resources import ( + AgentResources, + ModelResource, + ToolResource, + StorageResource, +) +from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext +from langbot_plugin.api.entities.builtin.agent_runner.capabilities import ( + AgentRunnerCapabilities, +) +from langbot_plugin.api.entities.builtin.agent_runner.permissions import ( + AgentRunnerPermissions, +) +from langbot_plugin.api.entities.builtin.agent_runner.event import ( + ConversationContext, + AgentEventContext, + ActorContext, + SubjectContext, +) +from langbot_plugin.api.entities.builtin.provider.message import ( + Message, + MessageChunk, + ContentElement, +) + + +class TestAgentRunContextV1: + """Test AgentRunContext v1 validation.""" + + def test_minimal_context_validate(self): + """Test minimal required fields validation.""" + trigger = AgentTrigger(type="message.received", source="pipeline") + input = AgentInput(text="Hello") + resources = AgentResources() + runtime = AgentRuntimeContext() + + ctx = AgentRunContext( + run_id="run_123", + trigger=trigger, + input=input, + resources=resources, + runtime=runtime, + ) + + assert ctx.run_id == "run_123" + assert ctx.trigger.type == "message.received" + assert ctx.input.text == "Hello" + assert ctx.messages == [] + assert ctx.config == {} + + def test_full_context_validate(self): + """Test full context with all optional fields.""" + trigger = AgentTrigger( + type="message.received", source="pipeline", timestamp=1234567890 + ) + conversation = ConversationContext( + session_id="sess_1", + conversation_id="conv_1", + launcher_type="person", + launcher_id="12345", + sender_id="user_1", + bot_uuid="bot_123", + pipeline_uuid="pipe_123", + ) + event = AgentEventContext( + event_type="message", + event_id="evt_1", + event_timestamp=1234567890, + ) + actor = ActorContext( + actor_type="user", + actor_id="user_1", + actor_name="Test User", + ) + subject = SubjectContext( + subject_type="message", + subject_id="msg_1", + ) + messages = [ + Message(role="user", content="Hi"), + Message(role="assistant", content="Hello"), + ] + input = AgentInput( + text="What's up?", + contents=[ContentElement(type="text", text="What's up?")], + ) + resources = AgentResources( + models=[ModelResource(model_id="gpt-4", model_type="chat")], + tools=[ToolResource(tool_name="search", tool_type="function")], + storage=StorageResource(plugin_storage=True, workspace_storage=True), + ) + runtime = AgentRuntimeContext( + langbot_version="1.0.0", + query_id=123, + trace_id="trace_abc", + deadline_at=1234568000, + ) + + ctx = AgentRunContext( + run_id="run_full", + trigger=trigger, + conversation=conversation, + event=event, + actor=actor, + subject=subject, + messages=messages, + input=input, + resources=resources, + runtime=runtime, + config={"model": "gpt-4"}, + ) + + assert ctx.run_id == "run_full" + assert ctx.conversation.launcher_type == "person" + assert ctx.resources.models[0].model_id == "gpt-4" + assert len(ctx.messages) == 2 + assert ctx.config["model"] == "gpt-4" + + def test_context_missing_required_field(self): + """Test that missing required fields raise validation error.""" + with pytest.raises(pydantic.ValidationError): + AgentRunContext( + # Missing run_id, trigger, input, resources, runtime + ) + + def test_context_model_validate_from_dict(self): + """Test model_validate from dict (as LangBot will send).""" + data = { + "run_id": "run_dict", + "trigger": {"type": "message.received", "source": "pipeline"}, + "input": {"text": "Hello from dict"}, + "resources": {}, + "runtime": {"sdk_protocol_version": "1"}, + } + + ctx = AgentRunContext.model_validate(data) + assert ctx.run_id == "run_dict" + assert ctx.input.text == "Hello from dict" + + +class TestAgentRunResultV1: + """Test AgentRunResult v1 validation for each type.""" + + def test_message_delta_validate(self): + """Test message.delta result.""" + chunk = MessageChunk(role="assistant", content="Hello") + result = AgentRunResult.message_delta(chunk) + + assert result.type == AgentRunResultType.MESSAGE_DELTA + assert "chunk" in result.data + assert result.data["chunk"]["role"] == "assistant" + + def test_message_completed_validate(self): + """Test message.completed result.""" + message = Message(role="assistant", content="Complete response") + result = AgentRunResult.message_completed(message) + + assert result.type == AgentRunResultType.MESSAGE_COMPLETED + assert "message" in result.data + assert result.data["message"]["role"] == "assistant" + + def test_tool_call_started_validate(self): + """Test tool.call.started result.""" + result = AgentRunResult.tool_call_started( + tool_call_id="call_1", + tool_name="weather", + parameters={"city": "Tokyo"}, + ) + + assert result.type == AgentRunResultType.TOOL_CALL_STARTED + assert result.data["tool_call_id"] == "call_1" + assert result.data["tool_name"] == "weather" + assert result.data["parameters"]["city"] == "Tokyo" + + def test_tool_call_completed_validate(self): + """Test tool.call.completed result.""" + result = AgentRunResult.tool_call_completed( + tool_call_id="call_1", + tool_name="weather", + result={"temp": 25, "condition": "sunny"}, + error=None, + ) + + assert result.type == AgentRunResultType.TOOL_CALL_COMPLETED + assert result.data["result"]["temp"] == 25 + + def test_tool_call_completed_with_error(self): + """Test tool.call.completed with error.""" + result = AgentRunResult.tool_call_completed( + tool_call_id="call_2", + tool_name="weather", + result=None, + error="API timeout", + ) + + assert result.type == AgentRunResultType.TOOL_CALL_COMPLETED + assert result.data["error"] == "API timeout" + + def test_state_updated_validate(self): + """Test state.updated result.""" + result = AgentRunResult.state_updated( + key="external_conversation_id", + value="abc123", + ) + + assert result.type == AgentRunResultType.STATE_UPDATED + assert result.data["key"] == "external_conversation_id" + assert result.data["value"] == "abc123" + + def test_run_completed_validate(self): + """Test run.completed result.""" + message = Message(role="assistant", content="Done") + result = AgentRunResult.run_completed(message=message, finish_reason="stop") + + assert result.type == AgentRunResultType.RUN_COMPLETED + assert result.data["finish_reason"] == "stop" + assert result.data["message"]["role"] == "assistant" + + def test_run_completed_without_message(self): + """Test run.completed without message (when message.completed already sent).""" + result = AgentRunResult.run_completed(finish_reason="stop") + + assert result.type == AgentRunResultType.RUN_COMPLETED + assert result.data["finish_reason"] == "stop" + assert "message" not in result.data + + def test_run_failed_validate(self): + """Test run.failed result.""" + result = AgentRunResult.run_failed( + error="Upstream timeout", + code="upstream.timeout", + retryable=True, + ) + + assert result.type == AgentRunResultType.RUN_FAILED + assert result.data["error"] == "Upstream timeout" + assert result.data["code"] == "upstream.timeout" + assert result.data["retryable"] is True + + def test_run_failed_default_code(self): + """Test run.failed with default code.""" + result = AgentRunResult.run_failed(error="Something went wrong") + + assert result.data["code"] == "runner.error" + + def test_action_requested_validate(self): + """Test action.requested result.""" + result = AgentRunResult.action_requested( + action="platform.message.edit", + parameters={"message_id": "msg_1", "new_text": "Updated"}, + ) + + assert result.type == AgentRunResultType.ACTION_REQUESTED + assert result.data["action"] == "platform.message.edit" + + def test_result_model_dump_json(self): + """Test model_dump(mode='json') for serialization.""" + message = Message(role="assistant", content="Test") + result = AgentRunResult.message_completed(message) + + dumped = result.model_dump(mode="json") + assert dumped["type"] == "message.completed" + assert isinstance(dumped["data"]["message"]["content"], str) + + +class TestAgentInput: + """Test AgentInput helpers.""" + + def test_to_text_from_text_field(self): + """Test to_text when text field is set.""" + input = AgentInput(text="Hello world") + assert input.to_text() == "Hello world" + + def test_to_text_from_contents(self): + """Test to_text from content elements.""" + input = AgentInput( + contents=[ + ContentElement(type="text", text="Hello"), + ContentElement(type="text", text="world"), + ] + ) + assert input.to_text() == "Hello world" + + def test_to_text_empty(self): + """Test to_text with no text.""" + input = AgentInput( + contents=[ + ContentElement( + type="image_url", image_url={"url": "http://example.com"} + ) + ] + ) + assert input.to_text() == "" + + +class TestCapabilitiesAndPermissions: + """Test AgentRunnerCapabilities and AgentRunnerPermissions.""" + + def test_capabilities_defaults(self): + """Test all capabilities default to False.""" + caps = AgentRunnerCapabilities() + assert not caps.streaming + assert not caps.tool_calling + assert not caps.knowledge_retrieval + assert not caps.multimodal_input + assert not caps.event_context + assert not caps.platform_api + assert not caps.interrupt + assert not caps.stateful_session + + def test_permissions_defaults(self): + """Test all permissions default to empty lists.""" + perms = AgentRunnerPermissions() + assert perms.models == [] + assert perms.tools == [] + assert perms.knowledge_bases == [] + assert perms.storage == [] + assert perms.files == [] + assert perms.platform_api == [] + + def test_capabilities_from_dict(self): + """Test capabilities from manifest data.""" + caps = AgentRunnerCapabilities( + streaming=True, + tool_calling=True, + stateful_session=True, + ) + assert caps.streaming + assert caps.tool_calling + assert caps.stateful_session + + def test_permissions_from_dict(self): + """Test permissions from manifest data.""" + perms = AgentRunnerPermissions( + models=["list", "invoke", "stream"], + tools=["list", "detail", "call"], + storage=["plugin", "workspace"], + ) + assert perms.models == ["list", "invoke", "stream"] + assert perms.tools == ["list", "detail", "call"] + assert perms.storage == ["plugin", "workspace"] diff --git a/tests/runtime/plugin/test_mgr_agent_runner.py b/tests/runtime/plugin/test_mgr_agent_runner.py new file mode 100644 index 00000000..f3a02999 --- /dev/null +++ b/tests/runtime/plugin/test_mgr_agent_runner.py @@ -0,0 +1,478 @@ +"""Tests for PluginManager AgentRunner methods (Protocol v1).""" + +from __future__ import annotations + +import pytest +import typing +from unittest.mock import Mock + +from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext +from langbot_plugin.api.entities.builtin.agent_runner.result import AgentRunResult +from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources +from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext +from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk +from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner + + +class MockAgentRunner(AgentRunner): + """Mock AgentRunner for testing.""" + + async def run( + self, ctx: AgentRunContext + ) -> typing.AsyncGenerator[AgentRunResult, None]: + """Mock run that yields results.""" + yield AgentRunResult.message_completed( + Message(role="assistant", content=f"Echo: {ctx.input.to_text()}") + ) + yield AgentRunResult.run_completed(finish_reason="stop") + + +class StreamingAgentRunner(AgentRunner): + """Mock AgentRunner that streams.""" + + async def run( + self, ctx: AgentRunContext + ) -> typing.AsyncGenerator[AgentRunResult, None]: + """Mock run that streams chunks using MessageChunk.""" + for word in ["Hello", " ", "world"]: + chunk = MessageChunk(role="assistant", content=word) + yield AgentRunResult.message_delta(chunk) + yield AgentRunResult.run_completed(finish_reason="stop") + + +class FailingAgentRunner(AgentRunner): + """Mock AgentRunner that fails.""" + + async def run( + self, ctx: AgentRunContext + ) -> typing.AsyncGenerator[AgentRunResult, None]: + """Mock run that raises an exception after yielding.""" + # Must have at least one yield to be an async generator + chunk = MessageChunk(role="assistant", content="Starting...") + yield AgentRunResult.message_delta(chunk) + raise RuntimeError("Intentional test failure") + + +def create_mock_component_manifest( + runner_name: str, + spec: dict | None = None, +): + """Create a mock ComponentManifest-like object.""" + mock_manifest = Mock() + mock_manifest.kind = "AgentRunner" + mock_manifest.metadata = Mock() + mock_manifest.metadata.name = runner_name + mock_manifest.metadata.description = f"Test runner {runner_name}" + mock_manifest.spec = spec or { + "protocol_version": "1", + "config": [], + "capabilities": {}, + "permissions": {}, + } + mock_manifest.model_dump = Mock( + return_value={ + "kind": "AgentRunner", + "metadata": { + "name": runner_name, + "description": f"Test runner {runner_name}", + }, + "spec": mock_manifest.spec, + } + ) + return mock_manifest + + +def create_mock_plugin( + author: str, + name: str, + runner_components: list[tuple[str, AgentRunner | None]], + capabilities: dict | None = None, + permissions: dict | None = None, + mock_handler_responses: list[list[dict]] | None = None, +): + """Create a mock plugin container with AgentRunner components. + + Args: + author: Plugin author + name: Plugin name + runner_components: List of (runner_name, runner_instance) tuples + capabilities: Optional capabilities dict + permissions: Optional permissions dict + mock_handler_responses: Optional list of response lists for each runner. + Each element is a list of responses to yield for that runner. + """ + plugin = Mock() + plugin.manifest = Mock() + plugin.manifest.metadata = Mock() + plugin.manifest.metadata.author = author + plugin.manifest.metadata.name = name + + components = [] + for runner_name, runner_instance in runner_components: + spec = { + "protocol_version": "1", + "config": [], + "capabilities": capabilities or {}, + "permissions": permissions or {}, + } + component = Mock() + component.manifest = create_mock_component_manifest(runner_name, spec) + component.component_instance = runner_instance + components.append(component) + + plugin.components = components + plugin.status = Mock() + plugin.status.value = "INITIALIZED" + plugin.enabled = True + + # Mock runtime plugin handler for forwarding + if mock_handler_responses: + async def mock_call_action_generator(action, data, timeout=300): + runner_name = data.get("runner_name") + # Find matching responses for this runner + for idx, (rname, _) in enumerate(runner_components): + if rname == runner_name and idx < len(mock_handler_responses): + for resp in mock_handler_responses[idx]: + yield {"ok": True, "data": resp} + return + # No matching responses found + yield {"ok": False, "message": f"No mock responses for {runner_name}"} + + mock_handler = Mock() + mock_handler.call_action_generator = mock_call_action_generator + plugin._runtime_plugin_handler = mock_handler + else: + # Default: no handler (will cause handler_not_found error) + plugin._runtime_plugin_handler = None + + return plugin + + +def create_run_context() -> AgentRunContext: + """Create a valid AgentRunContext for testing.""" + return AgentRunContext( + run_id="test_run", + trigger=AgentTrigger(type="message.received"), + input=AgentInput(text="Hello"), + resources=AgentResources(), + runtime=AgentRuntimeContext(), + ) + + +class TestListAgentRunners: + """Test PluginManager.list_agent_runners v1 protocol.""" + + @pytest.mark.anyio + async def test_single_plugin_single_runner(self): + """Test listing a plugin with one runner.""" + from langbot_plugin.runtime.plugin.mgr import PluginManager + from langbot_plugin.runtime.context import RuntimeContext + + mock_context = Mock(spec=RuntimeContext) + mgr = PluginManager(mock_context) + + runner = MockAgentRunner() + plugin = create_mock_plugin("test-author", "test-plugin", [("default", runner)]) + mgr.plugins = [plugin] + + runners = await mgr.list_agent_runners() + + assert len(runners) == 1 + assert runners[0]["plugin_author"] == "test-author" + assert runners[0]["plugin_name"] == "test-plugin" + assert runners[0]["runner_name"] == "default" + assert runners[0]["protocol_version"] == "1" + assert "capabilities" in runners[0] + assert "permissions" in runners[0] + + @pytest.mark.anyio + async def test_single_plugin_multiple_runners(self): + """Test listing a plugin with multiple runners (key feature of v1).""" + from langbot_plugin.runtime.plugin.mgr import PluginManager + from langbot_plugin.runtime.context import RuntimeContext + + mock_context = Mock(spec=RuntimeContext) + mgr = PluginManager(mock_context) + + # Create plugin with multiple AgentRunner components + plugin = create_mock_plugin( + "test-author", + "multi-runner-plugin", + [ + ("default", MockAgentRunner()), + ("streaming", StreamingAgentRunner()), + ("tool_based", MockAgentRunner()), + ], + capabilities={"streaming": True, "tool_calling": True}, + permissions={"models": ["invoke"], "tools": ["call"]}, + ) + mgr.plugins = [plugin] + + runners = await mgr.list_agent_runners() + + assert len(runners) == 3 + runner_names = [r["runner_name"] for r in runners] + assert "default" in runner_names + assert "streaming" in runner_names + assert "tool_based" in runner_names + + # Check capabilities and permissions are included + for runner in runners: + assert runner["protocol_version"] == "1" + assert "capabilities" in runner + assert "permissions" in runner + + @pytest.mark.anyio + async def test_include_plugins_filter(self): + """Test include_plugins filtering.""" + from langbot_plugin.runtime.plugin.mgr import PluginManager + from langbot_plugin.runtime.context import RuntimeContext + + mock_context = Mock(spec=RuntimeContext) + mgr = PluginManager(mock_context) + + plugin1 = create_mock_plugin( + "author1", "plugin1", [("runner1", MockAgentRunner())] + ) + plugin2 = create_mock_plugin( + "author2", "plugin2", [("runner2", MockAgentRunner())] + ) + mgr.plugins = [plugin1, plugin2] + + # Only include plugin1 + runners = await mgr.list_agent_runners(include_plugins=["author1/plugin1"]) + assert len(runners) == 1 + assert runners[0]["plugin_author"] == "author1" + + # Empty filter returns all + runners = await mgr.list_agent_runners() + assert len(runners) == 2 + + +class TestRunAgent: + """Test PluginManager.run_agent v1 protocol.""" + + @pytest.mark.anyio + async def test_run_agent_success_streaming(self): + """Test successful run_agent with streaming output.""" + from langbot_plugin.runtime.plugin.mgr import PluginManager + from langbot_plugin.runtime.context import RuntimeContext + + mock_context = Mock(spec=RuntimeContext) + mgr = PluginManager(mock_context) + + # Create mock responses for streaming + mock_responses = [ + {"type": "message.delta", "data": {"chunk": {"role": "assistant", "content": "Hello"}}}, + {"type": "message.delta", "data": {"chunk": {"role": "assistant", "content": " world"}}}, + {"type": "run.completed", "data": {"finish_reason": "stop"}}, + ] + + plugin = create_mock_plugin( + "test-author", "test-plugin", [("streaming", None)], + mock_handler_responses=[mock_responses] + ) + mgr.plugins = [plugin] + + ctx = create_run_context() + results = [] + async for result in mgr.run_agent( + "test-author", + "test-plugin", + "streaming", + ctx.model_dump(mode="json"), + ): + results.append(result) + + # Should have streaming chunks + run.completed + assert len(results) >= 2 + assert results[-1]["type"] == "run.completed" + + @pytest.mark.anyio + async def test_run_agent_plugin_not_found(self): + """Test run_agent returns run.failed when plugin not found.""" + from langbot_plugin.runtime.plugin.mgr import PluginManager + from langbot_plugin.runtime.context import RuntimeContext + + mock_context = Mock(spec=RuntimeContext) + mgr = PluginManager(mock_context) + mgr.plugins = [] + + ctx = create_run_context() + results = [] + async for result in mgr.run_agent( + "unknown", + "unknown-plugin", + "default", + ctx.model_dump(mode="json"), + ): + results.append(result) + + assert len(results) == 1 + assert results[0]["type"] == "run.failed" + assert results[0]["data"]["code"] == "runner.plugin_not_found" + + @pytest.mark.anyio + async def test_run_agent_runner_not_found(self): + """Test run_agent returns run.failed when runner not found.""" + from langbot_plugin.runtime.plugin.mgr import PluginManager + from langbot_plugin.runtime.context import RuntimeContext + + mock_context = Mock(spec=RuntimeContext) + mgr = PluginManager(mock_context) + + # Plugin exists but no matching runner + plugin = create_mock_plugin( + "test-author", "test-plugin", [("other_runner", MockAgentRunner())] + ) + mgr.plugins = [plugin] + + ctx = create_run_context() + results = [] + async for result in mgr.run_agent( + "test-author", + "test-plugin", + "default", # Not found + ctx.model_dump(mode="json"), + ): + results.append(result) + + assert len(results) == 1 + assert results[0]["type"] == "run.failed" + assert results[0]["data"]["code"] == "runner.not_found" + + @pytest.mark.anyio + async def test_run_agent_runner_exception_converted_to_run_failed(self): + """Test that runner exceptions are converted to run.failed (critical requirement).""" + from langbot_plugin.runtime.plugin.mgr import PluginManager + from langbot_plugin.runtime.context import RuntimeContext + + mock_context = Mock(spec=RuntimeContext) + mgr = PluginManager(mock_context) + + # Create mock responses simulating an exception + mock_responses = [ + {"type": "message.delta", "data": {"chunk": {"role": "assistant", "content": "Starting..."}}}, + {"type": "run.failed", "data": {"error": "Intentional test failure", "code": "runner.exception"}}, + ] + + plugin = create_mock_plugin( + "test-author", "test-plugin", [("failing", None)], + mock_handler_responses=[mock_responses] + ) + mgr.plugins = [plugin] + + ctx = create_run_context() + results = [] + async for result in mgr.run_agent( + "test-author", + "test-plugin", + "failing", + ctx.model_dump(mode="json"), + ): + results.append(result) + + # Must convert exception to run.failed, not raise + assert len(results) >= 1 + assert results[-1]["type"] == "run.failed" + assert results[-1]["data"]["code"] == "runner.exception" + assert "Intentional test failure" in results[-1]["data"]["error"] + + @pytest.mark.anyio + async def test_run_agent_context_validation_failure(self): + """Test that invalid context produces run.failed when forwarded.""" + from langbot_plugin.runtime.plugin.mgr import PluginManager + from langbot_plugin.runtime.context import RuntimeContext + + mock_context = Mock(spec=RuntimeContext) + mgr = PluginManager(mock_context) + + # Create mock responses for context validation failure + mock_responses = [ + {"type": "run.failed", "data": {"error": "Context validation failed", "code": "runner.context_invalid"}}, + ] + + plugin = create_mock_plugin( + "test-author", "test-plugin", [("default", None)], + mock_handler_responses=[mock_responses] + ) + mgr.plugins = [plugin] + + # Invalid context (missing required fields) + invalid_context = {"invalid": "data"} + + results = [] + async for result in mgr.run_agent( + "test-author", + "test-plugin", + "default", + invalid_context, + ): + results.append(result) + + assert len(results) == 1 + assert results[0]["type"] == "run.failed" + assert results[0]["data"]["code"] == "runner.context_invalid" + + @pytest.mark.anyio + async def test_run_agent_handler_not_found(self): + """Test run_agent when plugin has no runtime handler.""" + from langbot_plugin.runtime.plugin.mgr import PluginManager + from langbot_plugin.runtime.context import RuntimeContext + + mock_context = Mock(spec=RuntimeContext) + mgr = PluginManager(mock_context) + + # Plugin without handler (default when no mock_handler_responses provided) + plugin = create_mock_plugin("test-author", "test-plugin", [("default", None)]) + mgr.plugins = [plugin] + + ctx = create_run_context() + results = [] + async for result in mgr.run_agent( + "test-author", + "test-plugin", + "default", + ctx.model_dump(mode="json"), + ): + results.append(result) + + assert len(results) == 1 + assert results[0]["type"] == "run.failed" + assert results[0]["data"]["code"] == "runner.handler_not_found" + + @pytest.mark.anyio + async def test_run_agent_runner_not_initialized_forwarded(self): + """Test run_agent when plugin handler returns not_initialized error.""" + from langbot_plugin.runtime.plugin.mgr import PluginManager + from langbot_plugin.runtime.context import RuntimeContext + + mock_context = Mock(spec=RuntimeContext) + mgr = PluginManager(mock_context) + + # Mock responses for not initialized error + mock_responses = [ + {"type": "run.failed", "data": {"error": "AgentRunner default not initialized", "code": "runner.not_initialized"}}, + ] + + plugin = create_mock_plugin( + "test-author", "test-plugin", [("default", None)], + mock_handler_responses=[mock_responses] + ) + mgr.plugins = [plugin] + + ctx = create_run_context() + results = [] + async for result in mgr.run_agent( + "test-author", + "test-plugin", + "default", + ctx.model_dump(mode="json"), + ): + results.append(result) + + assert len(results) == 1 + assert results[0]["type"] == "run.failed" + assert results[0]["data"]["code"] == "runner.not_initialized" From 85a0a73742c437e4a4bd30b4d3e68cd03acbc3cf Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Mon, 11 May 2026 21:44:48 +0800 Subject: [PATCH 03/16] refactor(agent-runner): improve AgentRunAPIProxy and add state scope support - Refactor AgentRunAPIProxy to inherit from LangBotAPIProxy, reducing code duplication - Pre-compute allowed resource IDs (frozenset) in __init__ for O(1) permission validation - Add STATE_SCOPE_LITERAL type annotation in AgentRunResult.state_updated() - Add AgentRunState TypedDict with 4 scopes (conversation, actor, subject, runner) - Remove unused base64 import from agent_run_api.py - Update documentation for params and state protocol Co-Authored-By: Claude Opus 4.7 --- .../CONTEXT_PARAMS_STATE.md | 478 +++++++++++++ .../agent-runner-pluginization/PROTOCOL_V1.md | 69 +- .../components/agent_runner/runner.py | 30 +- .../entities/builtin/agent_runner/__init__.py | 8 +- .../entities/builtin/agent_runner/context.py | 42 +- .../entities/builtin/agent_runner/result.py | 44 +- .../entities/builtin/agent_runner/state.py | 56 ++ src/langbot_plugin/api/proxies/__init__.py | 8 + .../api/proxies/agent_run_api.py | 284 ++++++++ .../runtime/io/handlers/plugin.py | 18 + .../agent_runner/test_context_result.py | 171 ++++- tests/api/proxies/__init__.py | 1 + tests/api/proxies/test_agent_run_api_proxy.py | 667 ++++++++++++++++++ tests/runtime/plugin/test_mgr_agent_runner.py | 4 +- 14 files changed, 1861 insertions(+), 19 deletions(-) create mode 100644 docs/agent-runner-pluginization/CONTEXT_PARAMS_STATE.md create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/state.py create mode 100644 src/langbot_plugin/api/proxies/agent_run_api.py create mode 100644 tests/api/proxies/__init__.py create mode 100644 tests/api/proxies/test_agent_run_api_proxy.py diff --git a/docs/agent-runner-pluginization/CONTEXT_PARAMS_STATE.md b/docs/agent-runner-pluginization/CONTEXT_PARAMS_STATE.md new file mode 100644 index 00000000..116af870 --- /dev/null +++ b/docs/agent-runner-pluginization/CONTEXT_PARAMS_STATE.md @@ -0,0 +1,478 @@ +# AgentRunContext params 和 scoped state 语义 + +本文档详细说明 AgentRunner Protocol v1 中 `params` 和 `state` 的语义、边界和使用方式。 + +## 概述 + +Protocol v1 引入两个新字段避免协议 drift: + +- `params`: 单次运行公开业务参数 +- `state`: Host 管理的 scoped 状态快照 + +这两个字段解决了不同 runner 实现(Dify、local、coze、n8n 等)可能引入平台特定字段的问题。 + +## 字段边界 + +### AgentRunContext 四个类似字段 + +| 字段 | 来源 | 持久性 | 读写 | 用途 | +|------|------|--------|------|------| +| `config` | Pipeline/Runner 配置 | 静态不变 | Runner 可读(Host 在初始化时写入) | Runner 实例配置 | +| `params` | Pipeline 前序 stage 或用户输入 | 非持久化 | Runner 只读 | 单次运行业务参数 | +| `state` | Host 持久存储 | 持久化 | Runner 可读/请求更新 | Runner scoped 状态 | +| `runtime.metadata` | Host/Runtime | 不持久化 | Runner 只读 | 可观测性信息(query_id、trace_id 等) | + +### 为什么需要 params + +Runner 可能需要接收单次运行的业务参数,例如: +- Workflow inputs(Dify workflow 输入变量) +- Prompt variables(注入到 prompt 的变量) +- Pipeline 前序 stage 生成的公开业务变量 +- 用户定义的运行参数 + +这些参数: +- 不应该是 `config`:config 是静态的,不会每次运行变化 +- 不应该是 `state`:params 是非持久化的,下次运行不会携带 +- 不等同于 LangBot `query.variables`:Host 应过滤内部变量、secrets、权限控制变量 + +### 为什么需要 scoped state + +Runner 可能需要维护状态,例如: +- 外部平台的 conversation/thread ID(Dify conversation_id、Slack thread_ts) +- 用户长期记忆或偏好(跨会话) +- 群组/频道设置(跨用户) +- Runner 实例级缓存或配置 + +这些状态: +- 不应该是 `config`:config 是静态的 +- 不应该是 `params`:state 是持久化的 +- 需要 scope 区分:conversation(当前会话)、actor(用户)、subject(群组)、runner(实例) + +## params 详细语义 + +### 定义 + +```python +params: dict[str, Any] = Field(default_factory=dict) +``` + +### 语义约束 + +1. **JSON-safe**: 所有值必须是 JSON-serializable +2. **只读**: Runner 不应修改 params +3. **非持久化**: Host 不应将 params 携带到下一次 run +4. **Host 过滤**: Host 应过滤内部变量、secrets、权限控制变量 + +### 用途示例 + +```python +# Dify workflow inputs +params = { + "workflow_input": "user_question", + "prompt_var": "context_summary", +} + +# n8n workflow parameters +params = { + "workflow_trigger_payload": {...}, + "node_output_from_previous_stage": {...}, +} + +# 用户定义参数 +params = { + "custom_temperature": 0.7, + "response_style": "concise", +} +``` + +### Runner 使用方式 + +```python +class MyRunner(AgentRunner): + async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: + # 只读访问 params + workflow_input = ctx.params.get("workflow_input", "") + temperature = ctx.params.get("custom_temperature", 0.7) + + # 不应修改 params + # ctx.params["new_key"] = "value" # 错误:不应修改 + + # 使用 params 进行业务逻辑 + ... +``` + +### Host 发送方式 + +LangBot Host 在构造 `AgentRunContext` 时: + +```python +context = AgentRunContext( + run_id="...", + trigger=..., + input=..., + params={ + # 从 Pipeline 前序 stage 获取 + "workflow_input": workflow_stage_output, + # 从用户配置获取 + "custom_temperature": runner_config.get("temperature"), + }, + resources=..., + state=..., + runtime=..., + config=..., +) +``` + +## state 详细语义 + +### AgentRunState 定义 + +```python +class AgentRunState(BaseModel): + conversation: dict[str, Any] = Field(default_factory=dict) + """当前会话 + 当前 runner 状态""" + + actor: dict[str, Any] = Field(default_factory=dict) + """当前用户跨会话状态""" + + subject: dict[str, Any] = Field(default_factory=dict) + """当前群组/频道/对象状态""" + + runner: dict[str, Any] = Field(default_factory=dict) + """Runner 实例级状态(谨慎使用)""" +``` + +### Scope 定义 + +| Scope | 持久化范围 | 示例用途 | +|-------|------------|----------| +| `conversation` | 当前会话 + 当前 runner | 外部平台 conversation/thread ID,会话级上下文 | +| `actor` | 当前用户跨所有会话 | 用户偏好、长期记忆、用户画像数据 | +| `subject` | 当前群组/频道/对象 | 群设置、频道上下文、共享状态 | +| `runner` | Runner 实例级(所有会话/用户) | Runner 级配置、缓存(谨慎使用) | + +### Key 命名约定 + +使用命名空间前缀避免冲突: + +- `external.*`: 外部平台相关状态 + - `external.conversation_id`: 外部平台会话 ID + - `external.thread_id`: 外部平台线程 ID + - `external.user_id`: 外部平台用户 ID + +- `memory.*`: 记忆相关状态 + - `memory.summary`: 会话摘要 + - `memory.preferences`: 用户偏好 + +- `config.*`: 配置相关状态 + - `config.model`: 当前使用模型 + - `config.language`: 语言设置 + +- `cache.*`: 缓存相关状态 + - `cache.last_response`: 上次响应 + - `cache.context_window`: 上下文窗口状态 + +### Runner 使用方式 + +#### 读取 state + +```python +class MyRunner(AgentRunner): + async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: + # 读取 conversation scope + external_conv_id = ctx.state.conversation.get("external.conversation_id") + + # 读取 actor scope + user_language = ctx.state.actor.get("preferred_language", "en") + + # 读取 subject scope + group_topic = ctx.state.subject.get("group_topic") + + # 读取 runner scope(谨慎使用) + cache_version = ctx.state.runner.get("cache_version") +``` + +#### 更新 state(请求 Host 持久化) + +```python +class MyRunner(AgentRunner): + async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: + # 请求更新 conversation scope + yield AgentRunResult.state_updated( + key="external.conversation_id", + value="dify_conv_123", + scope="conversation" + ) + + # 请求更新 actor scope + yield AgentRunResult.state_updated( + key="preferred_language", + value="zh", + scope="actor" + ) + + # 向后兼容:默认 scope="conversation" + yield AgentRunResult.state_updated( + key="external.thread_id", + value="thread_abc" + ) +``` + +### Host 处理方式 + +LangBot Host 在: + +1. **构造 context 时**:从持久存储加载 state snapshot + +```python +# 从数据库或其他持久存储加载 +conversation_state = await load_conversation_state(conversation_id, runner_name) +actor_state = await load_actor_state(user_id, runner_name) +subject_state = await load_subject_state(group_id, runner_name) +runner_state = await load_runner_state(runner_name) + +state = AgentRunState( + conversation=conversation_state, + actor=actor_state, + subject=subject_state, + runner=runner_state, +) + +context = AgentRunContext( + ..., + state=state, +) +``` + +2. **处理 state.updated 结果时**:持久化到存储 + +```python +# 处理 AgentRunResult +if result.type == AgentRunResultType.STATE_UPDATED: + scope = result.data["scope"] + key = result.data["key"] + value = result.data["value"] + + # 根据 scope 持久化到不同存储 + if scope == "conversation": + await save_conversation_state(conversation_id, runner_name, key, value) + elif scope == "actor": + await save_actor_state(user_id, runner_name, key, value) + elif scope == "subject": + await save_subject_state(group_id, runner_name, key, value) + elif scope == "runner": + await save_runner_state(runner_name, key, value) +``` + +## state.updated 结果 + +### SDK 定义 + +```python +@classmethod +def state_updated( + cls, + key: str, + value: Any, + scope: str = "conversation", +) -> AgentRunResult: + """创建 state.updated 结果。 + + Runner 请求 Host 持久化状态变更。 + + Args: + key: 状态 key,应使用命名空间前缀(如 external.conversation_id) + value: 状态值,必须 JSON-serializable + scope: 状态 scope,必须为 conversation/actor/subject/runner 之一 + 默认 "conversation" 向后兼容 + + Returns: + AgentRunResult with type="state.updated" + + Raises: + ValueError: 如果 scope 不是有效值 + """ + if scope not in VALID_STATE_SCOPES: + raise ValueError(f"Invalid scope '{scope}'. Must be one of: {', '.join(VALID_STATE_SCOPES)}") + + return cls( + type=AgentRunResultType.STATE_UPDATED, + data={"scope": scope, "key": key, "value": value}, + ) +``` + +### 向后兼容 + +SDK 保证向后兼容: + +```python +# 旧用法:不指定 scope +yield AgentRunResult.state_updated("external.conversation_id", "abc") +# 实际 scope="conversation" + +# 新用法:显式指定 scope +yield AgentRunResult.state_updated("preferred_language", "en", scope="actor") +``` + +### Scope 验证 + +SDK 验证 scope 必须为有效值: + +```python +# 无效 scope 会抛出 ValueError +yield AgentRunResult.state_updated("key", "value", scope="invalid") +# ValueError: Invalid scope 'invalid'. Must be one of: conversation, actor, subject, runner +``` + +## 避免协议 drift + +### 问题背景 + +不同 runner 实现(Dify、local、coze、n8n)可能引入平台特定字段: + +```python +# ❌ 错误:Dify runner 引入平台特定字段 +class DifyRunner(AgentRunner): + async def run(self, ctx): + # Dify 特定字段 + dify_conv_id = ctx.config.get("dify_conversation_id") + inputs = ctx.config.get("inputs") + +# ❌ 错误:Coze runner 引入平台特定字段 +class CozeRunner(AgentRunner): + async def run(self, ctx): + # Coze 特定字段 + coze_conversation_id = ctx.config.get("coze_conversation_id") + bot_id = ctx.config.get("bot_id") +``` + +这会导致协议 drift:每个 runner 都有自己的特定字段,Host 需要为每个 runner 定制逻辑。 + +### 正确做法 + +使用 params 和 scoped state: + +```python +# ✅ 正确:Dify runner 使用标准 params 和 state +class DifyRunner(AgentRunner): + async def run(self, ctx): + # params: workflow inputs + workflow_inputs = ctx.params + + # state: 外部 conversation ID + dify_conv_id = ctx.state.conversation.get("external.conversation_id") + + # 如果需要创建新 conversation + if not dify_conv_id: + dify_conv_id = await self._create_conversation() + yield AgentRunResult.state_updated( + "external.conversation_id", + dify_conv_id, + scope="conversation" + ) + +# ✅ 正确:Coze runner 使用标准 params 和 state +class CozeRunner(AgentRunner): + async def run(self, ctx): + # params: workflow inputs + workflow_inputs = ctx.params + + # state: 外部 conversation ID + coze_conv_id = ctx.state.conversation.get("external.conversation_id") + + # 如果需要创建新 conversation + if not coze_conv_id: + coze_conv_id = await self._create_conversation() + yield AgentRunResult.state_updated( + "external.conversation_id", + coze_conv_id, + scope="conversation" + ) +``` + +### 好处 + +1. **协议统一**: 所有 runner 使用相同的 params 和 state 语义 +2. **Host 简化**: Host 只需要处理通用的 params/state 逻辑,不需要为每个 runner 定制 +3. **Runner 可移植**: Runner 可以在不同 Host 实现之间移植 +4. **测试简化**: 测试可以使用统一的 params/state 结构 + +## 测试覆盖 + +SDK 提供完整测试覆盖: + +### params 测试 + +```python +def test_params_default_empty_dict(): + """params 默认为空 dict""" + ctx = AgentRunContext(...) + assert ctx.params == {} + assert isinstance(ctx.params, dict) + +def test_params_and_state_from_dict(): + """从 dict 构造 params 和 state""" + data = { + "run_id": "run_dict", + "trigger": {...}, + "input": {...}, + "params": {"workflow_input": "value1"}, + "state": {"conversation": {"external.conversation_id": "conv_abc"}}, + } + ctx = AgentRunContext.model_validate(data) + assert ctx.params["workflow_input"] == "value1" + assert ctx.state.conversation["external.conversation_id"] == "conv_abc" +``` + +### state 测试 + +```python +def test_state_default_factory(): + """state 默认所有 scope 为空 dict""" + state = AgentRunState() + assert state.conversation == {} + assert state.actor == {} + assert state.subject == {} + assert state.runner == {} + +def test_state_with_values(): + """state 可以有实际值""" + state = AgentRunState( + conversation={"external.conversation_id": "abc"}, + actor={"preferred_language": "zh"}, + ) + assert state.conversation["external.conversation_id"] == "abc" + assert state.actor["preferred_language"] == "zh" +``` + +### state_updated 测试 + +```python +def test_state_updated_backward_compatible(): + """向后兼容:默认 scope="conversation"""" + result = AgentRunResult.state_updated("external.conversation_id", "abc") + assert result.data["scope"] == "conversation" + +def test_state_updated_with_scope(): + """显式指定 scope""" + result = AgentRunResult.state_updated("preferred_language", "en", scope="actor") + assert result.data["scope"] == "actor" + +def test_state_updated_invalid_scope_raises(): + """无效 scope 抛出 ValueError""" + with pytest.raises(ValueError, match="Invalid scope"): + AgentRunResult.state_updated("key", "value", scope="invalid") +``` + +## 总结 + +AgentRunner Protocol v1 的 params 和 scoped state 语义: + +1. **params**: 单次运行业务参数,只读,非持久化,JSON-safe +2. **state**: Host 管理的 scoped 状态快照,持久化,Runner 可读/请求更新 +3. **四个 scope**: conversation、actor、subject、runner +4. **命名约定**: 使用命名空间前缀(external.*、memory.*、config.*、cache.*) +5. **向后兼容**: state_updated 默认 scope="conversation" +6. **避免 drift**: 不引入平台特定字段,使用标准 params/state + +这确保了协议的统一性和可移植性,简化了 Host 和 Runner 的实现。 \ No newline at end of file diff --git a/docs/agent-runner-pluginization/PROTOCOL_V1.md b/docs/agent-runner-pluginization/PROTOCOL_V1.md index 5fd7e6c5..90f2cc2d 100644 --- a/docs/agent-runner-pluginization/PROTOCOL_V1.md +++ b/docs/agent-runner-pluginization/PROTOCOL_V1.md @@ -107,11 +107,68 @@ class AgentRunContext(BaseModel): subject: SubjectContext | None = None messages: list[Message] = [] input: AgentInput + params: dict[str, Any] = {} resources: AgentResources + state: AgentRunState = AgentRunState() runtime: AgentRuntimeContext config: dict[str, Any] = {} ``` +字段边界: +- `config`: 静态 runner 配置,来自 pipeline/runner config。 +- `params`: 单次运行业务参数,只读,非持久化。 +- `state`: Host 管理的 scoped 状态快照,持久化。 +- `runtime.metadata`: Host/runtime 可观测性信息,非业务输入契约。 + +### 5.0.1 params + +单次运行公开业务参数。 + +语义: +- JSON-safe,runner 只读 +- 非持久化(不带到下一次 run) +- 不等同于 LangBot query.variables +- Host 应过滤内部变量、secrets、权限控制变量 + +用途: +- Workflow inputs +- Prompt variables +- Pipeline 前序 stage 生成的公开业务变量 +- 用户定义变量 + +### 5.0.2 AgentRunState + +```python +class AgentRunState(BaseModel): + conversation: dict[str, Any] = {} + actor: dict[str, Any] = {} + subject: dict[str, Any] = {} + runner: dict[str, Any] = {} +``` + +Host 管理的 scoped 状态快照。 + +语义: +- Scoped(conversation/actor/subject/runner) +- 持久化(Host 持久化并下次 run 重新加载) +- Runner 可读,通过 `state.updated` 结果请求更新 + +Scope 定义: +- `conversation`: 当前会话 + 当前 runner 状态。例如:外部平台 conversation/thread ID,会话级上下文。 +- `actor`: 当前用户跨会话状态。例如:用户偏好、长期记忆、用户画像数据。 +- `subject`: 当前群组/频道/对象状态。例如:群设置、频道上下文、共享状态。 +- `runner`: Runner 实例级状态(谨慎使用)。例如:runner 级配置或缓存。 + +Key 命名约定: +- 使用命名空间前缀:`external.*`, `memory.*`, `config.*`, `cache.*` +- 示例:`external.conversation_id`, `external.thread_id`, `memory.summary` + +重要: +- State 不是 config(静态 runner 配置)。 +- State 不是 params(单次运行业务参数)。 +- State 不是 runtime.metadata(Host 可观测性信息)。 +- State 更新应通过 `AgentRunResult.state_updated()` 请求。 + ### 5.1 AgentTrigger ```python @@ -264,13 +321,21 @@ LangBot 映射为 `Message`。 { "type": "state.updated", "data": { - "key": "external_conversation_id", + "scope": "conversation", + "key": "external.conversation_id", "value": "abc" } } ``` -本阶段只记录,不要求 LangBot 自动持久化。官方插件应优先使用 plugin storage。 +Runner 请求 Host 持久化状态变更。 + +参数: +- `scope`: 状态 scope,必须为 `conversation`、`actor`、`subject`、`runner` 之一。默认 `conversation`(向后兼容)。 +- `key`: 状态 key,应使用命名空间前缀(如 `external.conversation_id`)。 +- `value`: 状态值,必须 JSON-serializable。 + +SDK 定义协议;LangBot host 处理实际持久化。本阶段 Host 应支持持久化,Runner 应正确使用 scope。 ### 6.6 run.completed diff --git a/src/langbot_plugin/api/definition/components/agent_runner/runner.py b/src/langbot_plugin/api/definition/components/agent_runner/runner.py index ba344d2c..cc12311b 100644 --- a/src/langbot_plugin/api/definition/components/agent_runner/runner.py +++ b/src/langbot_plugin/api/definition/components/agent_runner/runner.py @@ -44,9 +44,14 @@ def get_capabilities(cls) -> AgentRunnerCapabilities: ) async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: - # Stream response from LLM - for chunk_text in ["Hello", " ", "world"]: - chunk = MessageChunk(role="assistant", content=chunk_text) + # Get API proxy with run_id for LLM/tool/KB calls + api = self.get_run_api(ctx) + + # Stream response from LLM (with run_id tracking) + model_uuid = ctx.resources.models[0].model_id + messages = ctx.messages + + async for chunk in api.invoke_llm_stream(model_uuid, messages): yield AgentRunResult.message_delta(chunk) # Final message @@ -58,6 +63,25 @@ async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None __kind__ = "AgentRunner" __protocol_version__ = "1" + def get_run_api(self, ctx: AgentRunContext) -> "AgentRunAPIProxy": + """Get an API proxy configured with the run context. + + Use this proxy for LLM calls, tool calls, and knowledge base retrieval + to ensure proper context tracking and resource authorization. + + Args: + ctx: The agent run context containing run_id, runtime.query_id, and resources. + + Returns: + AgentRunAPIProxy: API proxy with context for Host API calls. + """ + from langbot_plugin.api.proxies.agent_run_api import AgentRunAPIProxy + + return AgentRunAPIProxy( + ctx=ctx, + plugin_runtime_handler=self.plugin.plugin_runtime_handler, + ) + @classmethod def get_capabilities(cls) -> AgentRunnerCapabilities: """Get default capabilities for this runner. diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py b/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py index eb94268e..cfc17e11 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py @@ -17,6 +17,10 @@ StorageResource, ) from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext +from langbot_plugin.api.entities.builtin.agent_runner.state import ( + AgentRunState, + VALID_STATE_SCOPES, +) from langbot_plugin.api.entities.builtin.agent_runner.event import ( ConversationContext, AgentEventContext, @@ -46,6 +50,8 @@ "FileResource", "StorageResource", "AgentRuntimeContext", + "AgentRunState", + "VALID_STATE_SCOPES", "ConversationContext", "AgentEventContext", "ActorContext", @@ -56,4 +62,4 @@ # Legacy (deprecated) "AgentRunReturn", "create_legacy_context", -] +] \ No newline at end of file diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/context.py b/src/langbot_plugin/api/entities/builtin/agent_runner/context.py index 0f467bac..3e4974e6 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/context.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/context.py @@ -10,6 +10,7 @@ from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext +from langbot_plugin.api.entities.builtin.agent_runner.state import AgentRunState from langbot_plugin.api.entities.builtin.agent_runner.event import ( ConversationContext, AgentEventContext, @@ -30,9 +31,17 @@ class AgentRunContext(pydantic.BaseModel): - subject: what the event is about - messages: historical conversation messages - input: user input + - params: single-run public business parameters (read-only, non-persistent) - resources: authorized resources + - state: host-managed scoped state snapshot (durable) - runtime: host/environment info - config: runner instance configuration + + Field boundaries: + - config: Static runner configuration from pipeline/runner config. + - params: Single-run business parameters, read-only, non-persistent. + - state: Host-managed runner-scoped persistent state snapshot. + - runtime.metadata: Host/runtime observability info, not a business input contract. """ run_id: str @@ -59,9 +68,40 @@ class AgentRunContext(pydantic.BaseModel): input: AgentInput """User input.""" + params: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Single-run public business parameters. + + Semantics: + - JSON-safe, read-only for runner + - Non-persistent (not carried to next run) + - Not equivalent to LangBot query.variables + - Host should filter internal variables, secrets, permission control variables + + Use cases: + - Workflow inputs + - Prompt variables + - Pipeline pre-stage generated public business variables + - User-defined variables + """ + resources: AgentResources """Authorized resources.""" + state: AgentRunState = pydantic.Field(default_factory=AgentRunState) + """Host-managed scoped state snapshot. + + Semantics: + - Scoped (conversation/actor/subject/runner) + - Durable (host persists and reloads next run) + - Runner can read and request updates via state.updated result + + Scopes: + - conversation: Current conversation + current runner state + - actor: Current user long-term state or preferences + - subject: Current group/channel/object state + - runner: Runner instance-level state (use sparingly) + """ + runtime: AgentRuntimeContext """Runtime context.""" @@ -69,4 +109,4 @@ class AgentRunContext(pydantic.BaseModel): """Runner instance configuration.""" class Config: - arbitrary_types_allowed = True + arbitrary_types_allowed = True \ No newline at end of file diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/result.py b/src/langbot_plugin/api/entities/builtin/agent_runner/result.py index 1969aa52..f383da21 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/result.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/result.py @@ -7,6 +7,7 @@ import enum from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk +from langbot_plugin.api.entities.builtin.agent_runner.state import VALID_STATE_SCOPES, STATE_SCOPE_LITERAL class AgentRunResultType(str, enum.Enum): @@ -100,15 +101,48 @@ def tool_call_completed( ) @classmethod - def state_updated(cls, key: str, value: typing.Any) -> "AgentRunResult": + def state_updated( + cls, + key: str, + value: typing.Any, + scope: STATE_SCOPE_LITERAL = "conversation", + ) -> "AgentRunResult": """Create a state.updated result. - LangBot records this but does not auto-persist. - Official plugins should use plugin storage instead. + Runner requests host to persist a state change. + SDK defines the protocol; LangBot host handles actual persistence. + + Args: + key: State key, should use namespace prefix (e.g., external.conversation_id) + value: State value, must be JSON-serializable + scope: State scope - one of: conversation, actor, subject, runner. + Defaults to "conversation" for backward compatibility. + + Returns: + AgentRunResult with type="state.updated" and data containing scope/key/value. + + Raises: + ValueError: If scope is not one of the valid scopes. + + Example: + # Store external platform conversation ID + yield AgentRunResult.state_updated( + "external.conversation_id", + "abc123", + scope="conversation" + ) + + # Store user preference (backward compatible) + yield AgentRunResult.state_updated("preferred_language", "en") """ + if scope not in VALID_STATE_SCOPES: + raise ValueError( + f"Invalid scope '{scope}'. Must be one of: {', '.join(VALID_STATE_SCOPES)}" + ) + return cls( type=AgentRunResultType.STATE_UPDATED, - data={"key": key, "value": value}, + data={"scope": scope, "key": key, "value": value}, ) @classmethod @@ -160,4 +194,4 @@ def action_requested( return cls( type=AgentRunResultType.ACTION_REQUESTED, data={"action": action, "parameters": parameters}, - ) + ) \ No newline at end of file diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/state.py b/src/langbot_plugin/api/entities/builtin/agent_runner/state.py new file mode 100644 index 00000000..30feded5 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/state.py @@ -0,0 +1,56 @@ +"""AgentRunState - scoped state snapshot for AgentRunner. + +State is managed by LangBot host, readable/writable by runner, +isolated by scope, and durable across runs. +""" +from __future__ import annotations + +import typing +import pydantic + + +class AgentRunState(pydantic.BaseModel): + """Scoped state snapshot passed to AgentRunner.run(). + + State is host-managed, runner-readable/writable, scope-isolated, and durable. + Host should populate state snapshot from persistent storage before each run, + and persist state updates from state.updated results after run completes. + + Scopes: + - conversation: State scoped to current conversation + current runner. + Example: external platform conversation/thread ID, conversation-level context. + - actor: State scoped to current user across all conversations. + Example: user preferences, long-term memory, user profile data. + - subject: State scoped to current group/channel/object. + Example: group settings, channel context, shared state. + - runner: State scoped to runner instance across all conversations/users. + Use sparingly - typically for runner-level configuration or caching. + + Key naming convention: + - Use namespace prefixes: external.*, memory.*, config.*, cache.* + - Example: external.conversation_id, external.thread_id, memory.summary + + Important: + - State is NOT config (static runner configuration). + - State is NOT params (single-run business parameters). + - State is NOT runtime.metadata (host observability info). + - State changes should be requested via AgentRunResult.state_updated(). + """ + + conversation: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """State scoped to current conversation + current runner.""" + + actor: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """State scoped to current user across all conversations.""" + + subject: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """State scoped to current group/channel/object.""" + + runner: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """State scoped to runner instance. Use sparingly.""" + + +# Valid scope names for state.updated +STATE_SCOPE_LITERAL = typing.Literal["conversation", "actor", "subject", "runner"] + +VALID_STATE_SCOPES: tuple[str, ...] = ("conversation", "actor", "subject", "runner") \ No newline at end of file diff --git a/src/langbot_plugin/api/proxies/__init__.py b/src/langbot_plugin/api/proxies/__init__.py index 0652c2fe..80dfc75e 100644 --- a/src/langbot_plugin/api/proxies/__init__.py +++ b/src/langbot_plugin/api/proxies/__init__.py @@ -1 +1,9 @@ """此包包含了对于 LangBot 向插件提供的 API 的代理类。""" + +from langbot_plugin.api.proxies.langbot_api import LangBotAPIProxy +from langbot_plugin.api.proxies.agent_run_api import AgentRunAPIProxy + +__all__ = [ + "LangBotAPIProxy", + "AgentRunAPIProxy", +] diff --git a/src/langbot_plugin/api/proxies/agent_run_api.py b/src/langbot_plugin/api/proxies/agent_run_api.py new file mode 100644 index 00000000..87be513b --- /dev/null +++ b/src/langbot_plugin/api/proxies/agent_run_api.py @@ -0,0 +1,284 @@ +"""AgentRun API Proxy for AgentRunner components. + +This proxy provides a restricted API for AgentRunner execution, +with all capabilities explicitly authorized through ctx.resources. +""" + +from __future__ import annotations + +from typing import Any + +from langbot_plugin.runtime.io.handler import Handler +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction +from langbot_plugin.api.proxies.langbot_api import LangBotAPIProxy +from langbot_plugin.api.entities.builtin.provider import message as provider_message +from langbot_plugin.api.entities.builtin.resource import tool as resource_tool +from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext + + +class PermissionDeniedError(Exception): + """Raised when an API call is not authorized by ctx.resources.""" + + pass + + +class AgentRunAPIProxy(LangBotAPIProxy): + """Restricted API proxy for AgentRunner execution. + + Inherits from LangBotAPIProxy and adds permission validation. + All resource access is validated against AgentRunContext.resources. + + Authorized APIs (validated against ctx.resources): + - invoke_llm() / invoke_llm_stream(): requires model in ctx.resources.models + - call_tool(): requires tool_name in ctx.resources.tools + - retrieve_knowledge(): requires kb_id in ctx.resources.knowledge_bases + - plugin_storage: requires ctx.resources.storage.plugin_storage=True + - workspace_storage: requires ctx.resources.storage.workspace_storage=True + - get_file(): requires file_id in ctx.resources.files + + Helper methods (local read from ctx.resources): + - get_allowed_models(): returns ctx.resources.models + - get_allowed_tools(): returns ctx.resources.tools + - get_allowed_knowledge_bases(): returns ctx.resources.knowledge_bases + - get_allowed_files(): returns ctx.resources.files + + Not available (platform actions, use AgentRunResult.action_requested instead): + - get_bots() / get_bot_info() / send_message() + """ + + ctx: AgentRunContext + """Agent run context containing run_id, resources, and runtime info.""" + + # Pre-computed allowed IDs for efficient O(1) validation + _allowed_model_ids: frozenset[str] + _allowed_tool_names: frozenset[str] + _allowed_kb_ids: frozenset[str] + _allowed_file_ids: frozenset[str] + + def __init__(self, ctx: AgentRunContext, plugin_runtime_handler: Handler): + super().__init__(plugin_runtime_handler) + self.ctx = ctx + # Pre-compute allowed IDs for efficient validation + self._allowed_model_ids = frozenset(m.model_id for m in ctx.resources.models) + self._allowed_tool_names = frozenset(t.tool_name for t in ctx.resources.tools) + self._allowed_kb_ids = frozenset(k.kb_id for k in ctx.resources.knowledge_bases) + self._allowed_file_ids = frozenset(f.file_id for f in ctx.resources.files) + + @property + def run_id(self) -> str: + """Unique identifier for this agent run.""" + return self.ctx.run_id + + @property + def query_id(self) -> int: + """Query ID from runtime context (for legacy compatibility).""" + return self.ctx.runtime.query_id or 0 + + # ================= Resource Helper Methods ================= + + def get_allowed_models(self) -> list[Any]: + """Get the list of models authorized for this run.""" + return self.ctx.resources.models + + def get_allowed_tools(self) -> list[Any]: + """Get the list of tools authorized for this run.""" + return self.ctx.resources.tools + + def get_allowed_knowledge_bases(self) -> list[Any]: + """Get the list of knowledge bases authorized for this run.""" + return self.ctx.resources.knowledge_bases + + def get_allowed_files(self) -> list[Any]: + """Get the list of files authorized for this run.""" + return self.ctx.resources.files + + # ================= Permission Validation ================= + + def _validate_model_access(self, llm_model_uuid: str) -> None: + if llm_model_uuid not in self._allowed_model_ids: + raise PermissionDeniedError( + f"Model '{llm_model_uuid}' is not authorized. " + f"Allowed models: {list(self._allowed_model_ids)}" + ) + + def _validate_tool_access(self, tool_name: str) -> None: + if tool_name not in self._allowed_tool_names: + raise PermissionDeniedError( + f"Tool '{tool_name}' is not authorized. " + f"Allowed tools: {list(self._allowed_tool_names)}" + ) + + def _validate_knowledge_base_access(self, kb_id: str) -> None: + if kb_id not in self._allowed_kb_ids: + raise PermissionDeniedError( + f"Knowledge base '{kb_id}' is not authorized. " + f"Allowed knowledge bases: {list(self._allowed_kb_ids)}" + ) + + def _validate_file_access(self, file_key: str) -> None: + if file_key not in self._allowed_file_ids: + raise PermissionDeniedError( + f"File '{file_key}' is not authorized. " + f"Allowed files: {list(self._allowed_file_ids)}" + ) + + def _validate_plugin_storage_access(self) -> None: + if not self.ctx.resources.storage.plugin_storage: + raise PermissionDeniedError("Plugin storage is not authorized.") + + def _validate_workspace_storage_access(self) -> None: + if not self.ctx.resources.storage.workspace_storage: + raise PermissionDeniedError("Workspace storage is not authorized.") + + # ================= LLM APIs (override to add validation + run_id) ================= + + async def invoke_llm( + self, + llm_model_uuid: str, + messages: list[provider_message.Message], + funcs: list[resource_tool.LLMTool] = [], + extra_args: dict[str, Any] = {}, + timeout: float | None = None, + ) -> provider_message.Message: + """Invoke an LLM model with permission validation and run_id.""" + self._validate_model_access(llm_model_uuid) + + effective_timeout = timeout if timeout is not None else 120.0 + resp = ( + await self.plugin_runtime_handler.call_action( + PluginToRuntimeAction.INVOKE_LLM, + { + "run_id": self.run_id, + "llm_model_uuid": llm_model_uuid, + "messages": [m.model_dump() for m in messages], + "funcs": [f.model_dump() for f in funcs], + "extra_args": extra_args, + "timeout": effective_timeout, + }, + timeout=effective_timeout, + ) + )["message"] + + return provider_message.Message.model_validate(resp) + + async def invoke_llm_stream( + self, + llm_model_uuid: str, + messages: list[provider_message.Message], + funcs: list[resource_tool.LLMTool] = [], + extra_args: dict[str, Any] = {}, + ): + """Invoke an LLM model with streaming, permission validation and run_id.""" + self._validate_model_access(llm_model_uuid) + + async for chunk_data in self.plugin_runtime_handler.call_action_generator( + PluginToRuntimeAction.INVOKE_LLM_STREAM, + { + "run_id": self.run_id, + "llm_model_uuid": llm_model_uuid, + "messages": [m.model_dump() for m in messages], + "funcs": [f.model_dump() for f in funcs], + "extra_args": extra_args, + }, + ): + yield provider_message.MessageChunk.model_validate(chunk_data["chunk"]) + + # ================= Tool API (different signature from parent) ================= + + async def call_tool( + self, + tool_name: str, + parameters: dict[str, Any], + session: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Call a tool with permission validation. + + Note: Simplified signature without session/query_id (obtained from ctx). + Returns 'result' key instead of 'tool_response'. + """ + self._validate_tool_access(tool_name) + + return ( + await self.plugin_runtime_handler.call_action( + PluginToRuntimeAction.CALL_TOOL, + { + "run_id": self.run_id, + "tool_name": tool_name, + "parameters": parameters, + "session": session or {}, + "query_id": self.query_id, + }, + timeout=180, + ) + )["result"] + + # ================= Knowledge Base API (override to add validation) ================= + + async def retrieve_knowledge( + self, + kb_id: str, + query_text: str, + top_k: int = 5, + filters: dict[str, Any] | None = None, + ) -> list[dict[str, Any]]: + """Retrieve from knowledge base with permission validation. + + Uses RETRIEVE_KNOWLEDGE_BASE action (pipeline-scoped) with run_id. + """ + self._validate_knowledge_base_access(kb_id) + + return ( + await self.plugin_runtime_handler.call_action( + PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE, + { + "run_id": self.run_id, + "query_id": self.query_id, + "kb_id": kb_id, + "query_text": query_text, + "top_k": top_k, + "filters": filters or {}, + }, + timeout=30, + ) + )["results"] + + # ================= Storage APIs (override to add validation) ================= + + async def set_plugin_storage(self, key: str, value: bytes) -> None: + self._validate_plugin_storage_access() + await super().set_plugin_storage(key, value) + + async def get_plugin_storage(self, key: str) -> bytes: + self._validate_plugin_storage_access() + return await super().get_plugin_storage(key) + + async def get_plugin_storage_keys(self) -> list[str]: + self._validate_plugin_storage_access() + return await super().get_plugin_storage_keys() + + async def delete_plugin_storage(self, key: str) -> None: + self._validate_plugin_storage_access() + await super().delete_plugin_storage(key) + + async def set_workspace_storage(self, key: str, value: bytes) -> None: + self._validate_workspace_storage_access() + await super().set_workspace_storage(key, value) + + async def get_workspace_storage(self, key: str) -> bytes: + self._validate_workspace_storage_access() + return await super().get_workspace_storage(key) + + async def get_workspace_storage_keys(self) -> list[str]: + self._validate_workspace_storage_access() + return await super().get_workspace_storage_keys() + + async def delete_workspace_storage(self, key: str) -> None: + self._validate_workspace_storage_access() + await super().delete_workspace_storage(key) + + # ================= File API ================= + + async def get_file(self, file_key: str) -> bytes: + """Get a file with permission validation.""" + self._validate_file_access(file_key) + return await super().get_config_file(file_key) \ No newline at end of file diff --git a/src/langbot_plugin/runtime/io/handlers/plugin.py b/src/langbot_plugin/runtime/io/handlers/plugin.py index fbcce3a8..5ee0c873 100644 --- a/src/langbot_plugin/runtime/io/handlers/plugin.py +++ b/src/langbot_plugin/runtime/io/handlers/plugin.py @@ -221,6 +221,24 @@ async def invoke_llm(data: dict[str, Any]) -> handler.ActionResponse: ) return handler.ActionResponse.success(result) + @self.action(PluginToRuntimeAction.INVOKE_LLM_STREAM) + async def invoke_llm_stream( + data: dict[str, Any], + ) -> AsyncGenerator[handler.ActionResponse, None]: + """Forward INVOKE_LLM_STREAM to LangBot control handler.""" + timeout = data.pop("timeout", 120.0) + if not isinstance(timeout, (int, float)) or timeout <= 0: + timeout = 120.0 + + async for chunk in self.context.control_handler.call_action_generator( + PluginToRuntimeAction.INVOKE_LLM_STREAM, + { + **data, + }, + timeout=float(timeout), + ): + yield handler.ActionResponse.success(chunk) + @self.action(PluginToRuntimeAction.INVOKE_EMBEDDING) async def invoke_embedding(data: dict[str, Any]) -> handler.ActionResponse: result = await self.context.control_handler.call_action( diff --git a/tests/api/entities/builtin/agent_runner/test_context_result.py b/tests/api/entities/builtin/agent_runner/test_context_result.py index 64c38bb4..4fda4131 100644 --- a/tests/api/entities/builtin/agent_runner/test_context_result.py +++ b/tests/api/entities/builtin/agent_runner/test_context_result.py @@ -19,6 +19,10 @@ StorageResource, ) from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext +from langbot_plugin.api.entities.builtin.agent_runner.state import ( + AgentRunState, + VALID_STATE_SCOPES, +) from langbot_plugin.api.entities.builtin.agent_runner.capabilities import ( AgentRunnerCapabilities, ) @@ -61,6 +65,74 @@ def test_minimal_context_validate(self): assert ctx.input.text == "Hello" assert ctx.messages == [] assert ctx.config == {} + # New fields: params and state have defaults + assert ctx.params == {} + assert ctx.state.conversation == {} + assert ctx.state.actor == {} + assert ctx.state.subject == {} + assert ctx.state.runner == {} + + def test_params_default_empty_dict(self): + """Test params defaults to empty dict.""" + trigger = AgentTrigger(type="message.received") + input = AgentInput(text="Hello") + resources = AgentResources() + runtime = AgentRuntimeContext() + + ctx = AgentRunContext( + run_id="run_params", + trigger=trigger, + input=input, + resources=resources, + runtime=runtime, + ) + + assert ctx.params == {} + assert isinstance(ctx.params, dict) + + def test_state_default_all_scopes_empty(self): + """Test state defaults with all scopes empty.""" + trigger = AgentTrigger(type="message.received") + input = AgentInput(text="Hello") + resources = AgentResources() + runtime = AgentRuntimeContext() + + ctx = AgentRunContext( + run_id="run_state", + trigger=trigger, + input=input, + resources=resources, + runtime=runtime, + ) + + assert ctx.state.conversation == {} + assert ctx.state.actor == {} + assert ctx.state.subject == {} + assert ctx.state.runner == {} + + def test_params_and_state_from_dict(self): + """Test constructing params and state from dict.""" + data = { + "run_id": "run_dict", + "trigger": {"type": "message.received", "source": "pipeline"}, + "input": {"text": "Hello"}, + "resources": {}, + "runtime": {}, + "params": {"workflow_input": "value1", "prompt_var": "value2"}, + "state": { + "conversation": {"external.conversation_id": "conv_abc"}, + "actor": {"preferred_language": "en"}, + "subject": {}, + "runner": {}, + }, + } + + ctx = AgentRunContext.model_validate(data) + + assert ctx.params["workflow_input"] == "value1" + assert ctx.params["prompt_var"] == "value2" + assert ctx.state.conversation["external.conversation_id"] == "conv_abc" + assert ctx.state.actor["preferred_language"] == "en" def test_full_context_validate(self): """Test full context with all optional fields.""" @@ -98,11 +170,19 @@ def test_full_context_validate(self): text="What's up?", contents=[ContentElement(type="text", text="What's up?")], ) + params = { + "workflow_input": "test_workflow", + "custom_var": 42, + } resources = AgentResources( models=[ModelResource(model_id="gpt-4", model_type="chat")], tools=[ToolResource(tool_name="search", tool_type="function")], storage=StorageResource(plugin_storage=True, workspace_storage=True), ) + state = AgentRunState( + conversation={"external.conversation_id": "conv_xyz"}, + actor={"memory.summary": "User likes coffee"}, + ) runtime = AgentRuntimeContext( langbot_version="1.0.0", query_id=123, @@ -119,7 +199,9 @@ def test_full_context_validate(self): subject=subject, messages=messages, input=input, + params=params, resources=resources, + state=state, runtime=runtime, config={"model": "gpt-4"}, ) @@ -129,6 +211,9 @@ def test_full_context_validate(self): assert ctx.resources.models[0].model_id == "gpt-4" assert len(ctx.messages) == 2 assert ctx.config["model"] == "gpt-4" + assert ctx.params["workflow_input"] == "test_workflow" + assert ctx.state.conversation["external.conversation_id"] == "conv_xyz" + assert ctx.state.actor["memory.summary"] == "User likes coffee" def test_context_missing_required_field(self): """Test that missing required fields raise validation error.""" @@ -152,6 +237,49 @@ def test_context_model_validate_from_dict(self): assert ctx.input.text == "Hello from dict" +class TestAgentRunState: + """Test AgentRunState entity.""" + + def test_state_default_factory(self): + """Test state creates with all empty dicts.""" + state = AgentRunState() + assert state.conversation == {} + assert state.actor == {} + assert state.subject == {} + assert state.runner == {} + + def test_state_with_values(self): + """Test state with actual values.""" + state = AgentRunState( + conversation={"external.conversation_id": "abc", "external.thread_id": "xyz"}, + actor={"preferred_language": "zh"}, + subject={"group_topic": "general"}, + runner={"cache_version": 1}, + ) + + assert state.conversation["external.conversation_id"] == "abc" + assert state.actor["preferred_language"] == "zh" + assert state.subject["group_topic"] == "general" + assert state.runner["cache_version"] == 1 + + def test_state_model_dump(self): + """Test state serialization.""" + state = AgentRunState( + conversation={"key": "value"}, + ) + dumped = state.model_dump() + assert dumped["conversation"]["key"] == "value" + assert dumped["actor"] == {} + + def test_valid_state_scopes_constant(self): + """Test VALID_STATE_SCOPES contains all scopes.""" + assert "conversation" in VALID_STATE_SCOPES + assert "actor" in VALID_STATE_SCOPES + assert "subject" in VALID_STATE_SCOPES + assert "runner" in VALID_STATE_SCOPES + assert len(VALID_STATE_SCOPES) == 4 + + class TestAgentRunResultV1: """Test AgentRunResult v1 validation for each type.""" @@ -210,17 +338,50 @@ def test_tool_call_completed_with_error(self): assert result.type == AgentRunResultType.TOOL_CALL_COMPLETED assert result.data["error"] == "API timeout" - def test_state_updated_validate(self): - """Test state.updated result.""" + def test_state_updated_backward_compatible(self): + """Test state.updated backward compatible (default scope=conversation).""" result = AgentRunResult.state_updated( - key="external_conversation_id", + key="external.conversation_id", value="abc123", ) assert result.type == AgentRunResultType.STATE_UPDATED - assert result.data["key"] == "external_conversation_id" + assert result.data["scope"] == "conversation" + assert result.data["key"] == "external.conversation_id" assert result.data["value"] == "abc123" + def test_state_updated_with_scope(self): + """Test state.updated with explicit scope.""" + result = AgentRunResult.state_updated( + key="preferred_language", + value="en", + scope="actor", + ) + + assert result.type == AgentRunResultType.STATE_UPDATED + assert result.data["scope"] == "actor" + assert result.data["key"] == "preferred_language" + assert result.data["value"] == "en" + + def test_state_updated_all_scopes(self): + """Test state.updated with all valid scopes.""" + for scope in VALID_STATE_SCOPES: + result = AgentRunResult.state_updated( + key="test_key", + value="test_value", + scope=scope, + ) + assert result.data["scope"] == scope + + def test_state_updated_invalid_scope_raises(self): + """Test state.updated with invalid scope raises ValueError.""" + with pytest.raises(ValueError, match="Invalid scope"): + AgentRunResult.state_updated( + key="test_key", + value="test_value", + scope="invalid_scope", + ) + def test_run_completed_validate(self): """Test run.completed result.""" message = Message(role="assistant", content="Done") @@ -352,4 +513,4 @@ def test_permissions_from_dict(self): ) assert perms.models == ["list", "invoke", "stream"] assert perms.tools == ["list", "detail", "call"] - assert perms.storage == ["plugin", "workspace"] + assert perms.storage == ["plugin", "workspace"] \ No newline at end of file diff --git a/tests/api/proxies/__init__.py b/tests/api/proxies/__init__.py new file mode 100644 index 00000000..a6ad11af --- /dev/null +++ b/tests/api/proxies/__init__.py @@ -0,0 +1 @@ +"""Tests for SDK API proxies.""" \ No newline at end of file diff --git a/tests/api/proxies/test_agent_run_api_proxy.py b/tests/api/proxies/test_agent_run_api_proxy.py new file mode 100644 index 00000000..9bac188b --- /dev/null +++ b/tests/api/proxies/test_agent_run_api_proxy.py @@ -0,0 +1,667 @@ +"""Tests for AgentRunAPIProxy restricted API surface and permission validation. + +These tests verify that AgentRunAPIProxy: +1. Only exposes APIs explicitly authorized through ctx.resources +2. Validates resource access before execution +3. Does NOT expose unrestricted global APIs +""" +from __future__ import annotations + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +import typing + +from langbot_plugin.api.proxies.agent_run_api import AgentRunAPIProxy, PermissionDeniedError +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction +from langbot_plugin.runtime.io.handler import Handler +from langbot_plugin.api.entities.builtin.provider.message import Message +from langbot_plugin.api.entities.builtin.resource.tool import LLMTool +from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext +from langbot_plugin.api.entities.builtin.agent_runner.resources import ( + AgentResources, + ModelResource, + ToolResource, + KnowledgeBaseResource, + StorageResource, + FileResource, +) +from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext +from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput + + +class MockHandler: + """Mock Handler for testing AgentRunAPIProxy.""" + + def __init__(self): + self.call_action_mock = AsyncMock() + self.call_action_generator_mock = AsyncMock() + + async def call_action(self, action: PluginToRuntimeAction, data: dict, timeout: float = 120): + """Mock call_action that returns expected response.""" + return await self.call_action_mock(action, data, timeout) + + def call_action_generator(self, action: PluginToRuntimeAction, data: dict, timeout: float = 120): + """Mock call_action_generator - returns an async generator.""" + return self.call_action_generator_mock(action, data, timeout) + + +def create_mock_context( + run_id: str = 'test_run', + query_id: int | None = None, + models: list[dict] | None = None, + tools: list[dict] | None = None, + knowledge_bases: list[dict] | None = None, + storage: dict | None = None, + files: list[dict] | None = None, +) -> AgentRunContext: + """Create a mock AgentRunContext for testing.""" + return AgentRunContext( + run_id=run_id, + trigger=AgentTrigger(type='user_message'), + input=AgentInput(content='test input'), + runtime=AgentRuntimeContext(query_id=query_id), + resources=AgentResources( + models=[ModelResource.model_validate(m) for m in (models or [])], + tools=[ToolResource.model_validate(t) for t in (tools or [])], + knowledge_bases=[KnowledgeBaseResource.model_validate(kb) for kb in (knowledge_bases or [])], + storage=StorageResource.model_validate(storage or {'plugin_storage': False, 'workspace_storage': False}), + files=[FileResource.model_validate(f) for f in (files or [])], + ), + ) + + +class TestAgentRunAPIProxyRestrictedAPISurface: + """Tests to verify AgentRunAPIProxy does NOT expose unrestricted global APIs.""" + + def test_does_not_expose_get_bots(self): + """AgentRunAPIProxy should NOT have get_bots method.""" + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert not hasattr(proxy, 'get_bots'), \ + "AgentRunAPIProxy should not expose get_bots (use AgentRunResult.action_requested)" + + def test_does_not_expose_send_message(self): + """AgentRunAPIProxy should NOT have send_message method.""" + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert not hasattr(proxy, 'send_message'), \ + "AgentRunAPIProxy should not expose send_message (use AgentRunResult.action_requested)" + + def test_does_not_expose_list_tools(self): + """AgentRunAPIProxy should NOT have list_tools method.""" + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert not hasattr(proxy, 'list_tools'), \ + "AgentRunAPIProxy should not expose list_tools (use get_allowed_tools() instead)" + + def test_does_not_expose_get_tool_detail(self): + """AgentRunAPIProxy should NOT have get_tool_detail method.""" + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert not hasattr(proxy, 'get_tool_detail'), \ + "AgentRunAPIProxy should not expose get_tool_detail (use get_allowed_tools() instead)" + + def test_does_not_expose_vector_upsert(self): + """AgentRunAPIProxy should NOT have vector_upsert method.""" + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert not hasattr(proxy, 'vector_upsert'), \ + "AgentRunAPIProxy should not expose vector_upsert (no vector resources defined)" + + def test_does_not_expose_vector_search(self): + """AgentRunAPIProxy should NOT have vector_search method.""" + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert not hasattr(proxy, 'vector_search'), \ + "AgentRunAPIProxy should not expose vector_search (no vector resources defined)" + + def test_does_not_expose_invoke_embedding(self): + """AgentRunAPIProxy should NOT have invoke_embedding method.""" + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert not hasattr(proxy, 'invoke_embedding'), \ + "AgentRunAPIProxy should not expose invoke_embedding (no embedding model resources defined)" + + def test_does_not_expose_get_llm_models(self): + """AgentRunAPIProxy should NOT have get_llm_models method.""" + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert not hasattr(proxy, 'get_llm_models'), \ + "AgentRunAPIProxy should not expose get_llm_models (use get_allowed_models() instead)" + + def test_does_not_expose_list_knowledge_bases(self): + """AgentRunAPIProxy should NOT have list_knowledge_bases method.""" + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert not hasattr(proxy, 'list_knowledge_bases'), \ + "AgentRunAPIProxy should not expose list_knowledge_bases (use get_allowed_knowledge_bases() instead)" + + def test_does_not_expose_get_config_file(self): + """AgentRunAPIProxy should NOT have get_config_file method.""" + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert not hasattr(proxy, 'get_config_file'), \ + "AgentRunAPIProxy should not expose get_config_file (use get_file() with file access validation)" + + def test_exposes_get_file_with_validation(self): + """AgentRunAPIProxy exposes get_file() with file access validation.""" + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert hasattr(proxy, 'get_file'), \ + "AgentRunAPIProxy should expose get_file() for authorized file access" + + def test_exposes_allowed_resource_helpers(self): + """AgentRunAPIProxy exposes resource helper methods.""" + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert hasattr(proxy, 'get_allowed_models') + assert hasattr(proxy, 'get_allowed_tools') + assert hasattr(proxy, 'get_allowed_knowledge_bases') + assert hasattr(proxy, 'get_allowed_files') + + def test_exposes_storage_methods_with_validation(self): + """AgentRunAPIProxy exposes storage methods with permission validation.""" + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert hasattr(proxy, 'set_plugin_storage') + assert hasattr(proxy, 'get_plugin_storage') + assert hasattr(proxy, 'delete_plugin_storage') + assert hasattr(proxy, 'set_workspace_storage') + assert hasattr(proxy, 'get_workspace_storage') + assert hasattr(proxy, 'delete_workspace_storage') + + def test_exposes_version_api(self): + """AgentRunAPIProxy exposes get_langbot_version (no authorization needed).""" + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert hasattr(proxy, 'get_langbot_version') + + +class TestAgentRunAPIProxyResourceValidation: + """Tests for resource validation in AgentRunAPIProxy.""" + + @pytest.mark.anyio + async def test_invoke_llm_with_authorized_model(self): + """invoke_llm succeeds when model is in ctx.resources.models.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = { + 'message': {'role': 'assistant', 'content': 'Hello back'} + } + + ctx = create_mock_context( + run_id='run_llm_test', + models=[{'model_id': 'model_001'}] + ) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + messages = [Message(role='user', content='Hello')] + result = await proxy.invoke_llm('model_001', messages) + + call_args = mock_handler.call_action_mock.call_args + data = call_args[0][1] + + assert data['run_id'] == 'run_llm_test' + assert data['llm_model_uuid'] == 'model_001' + + @pytest.mark.anyio + async def test_invoke_llm_with_unauthorized_model_raises_error(self): + """invoke_llm raises PermissionDeniedError when model is NOT authorized.""" + mock_handler = MockHandler() + + ctx = create_mock_context( + run_id='run_unauth', + models=[{'model_id': 'model_001'}] # Only model_001 is authorized + ) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + messages = [Message(role='user', content='Hello')] + + with pytest.raises(PermissionDeniedError) as exc_info: + await proxy.invoke_llm('model_999', messages) # model_999 is NOT authorized + + assert 'model_999' in str(exc_info.value) + assert 'not authorized' in str(exc_info.value) + + @pytest.mark.anyio + async def test_call_tool_with_authorized_tool(self): + """call_tool succeeds when tool is in ctx.resources.tools.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'result': {'status': 'success'}} + + ctx = create_mock_context( + run_id='run_tool_test', + query_id=123, + tools=[{'tool_name': 'web_search'}] + ) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + result = await proxy.call_tool('web_search', {'query': 'hello'}) + + call_args = mock_handler.call_action_mock.call_args + data = call_args[0][1] + + assert data['run_id'] == 'run_tool_test' + assert data['tool_name'] == 'web_search' + assert data['query_id'] == 123 # Auto-filled from ctx + + @pytest.mark.anyio + async def test_call_tool_with_unauthorized_tool_raises_error(self): + """call_tool raises PermissionDeniedError when tool is NOT authorized.""" + mock_handler = MockHandler() + + ctx = create_mock_context( + run_id='run_unauth', + tools=[{'tool_name': 'web_search'}] # Only web_search is authorized + ) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + with pytest.raises(PermissionDeniedError) as exc_info: + await proxy.call_tool('image_gen', {'prompt': 'hello'}) # image_gen NOT authorized + + assert 'image_gen' in str(exc_info.value) + assert 'not authorized' in str(exc_info.value) + + @pytest.mark.anyio + async def test_retrieve_knowledge_with_authorized_kb(self): + """retrieve_knowledge succeeds when kb is in ctx.resources.knowledge_bases.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'results': [{'text': 'doc1'}]} + + ctx = create_mock_context( + run_id='run_kb_test', + query_id=456, + knowledge_bases=[{'kb_id': 'kb_001'}] + ) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + # Note: query_id is NOT passed - auto-filled from ctx + results = await proxy.retrieve_knowledge('kb_001', 'search query') + + call_args = mock_handler.call_action_mock.call_args + data = call_args[0][1] + + assert data['run_id'] == 'run_kb_test' + assert data['kb_id'] == 'kb_001' + assert data['query_id'] == 456 # Auto-filled from ctx.runtime.query_id + + @pytest.mark.anyio + async def test_retrieve_knowledge_with_unauthorized_kb_raises_error(self): + """retrieve_knowledge raises PermissionDeniedError when kb is NOT authorized.""" + mock_handler = MockHandler() + + ctx = create_mock_context( + run_id='run_unauth', + knowledge_bases=[{'kb_id': 'kb_001'}] # Only kb_001 is authorized + ) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + with pytest.raises(PermissionDeniedError) as exc_info: + await proxy.retrieve_knowledge('kb_999', 'search query') # kb_999 NOT authorized + + assert 'kb_999' in str(exc_info.value) + assert 'not authorized' in str(exc_info.value) + + +class TestRetrieveKnowledgeAutoQueryId: + """Tests for retrieve_knowledge auto-using query_id from AgentRunContext.""" + + def test_query_id_property_from_runtime_context(self): + """query_id property returns ctx.runtime.query_id.""" + ctx = create_mock_context(run_id='test_run', query_id=789) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert proxy.query_id == 789 + + def test_query_id_defaults_to_zero_when_none(self): + """query_id defaults to 0 when ctx.runtime.query_id is None.""" + ctx = create_mock_context(run_id='test_run', query_id=None) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert proxy.query_id == 0 + + @pytest.mark.anyio + async def test_retrieve_knowledge_auto_uses_ctx_query_id(self): + """retrieve_knowledge() auto-uses query_id from ctx.runtime.query_id.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'results': []} + + ctx = create_mock_context( + run_id='run_auto_query', + query_id=100, # This should be auto-used + knowledge_bases=[{'kb_id': 'kb_001'}] + ) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + # Call retrieve_knowledge without explicit query_id parameter + await proxy.retrieve_knowledge('kb_001', 'search query') + + call_args = mock_handler.call_action_mock.call_args + data = call_args[0][1] + + # query_id should be auto-filled from ctx.runtime.query_id + assert data['query_id'] == 100 + + @pytest.mark.anyio + async def test_call_tool_auto_uses_ctx_query_id(self): + """call_tool() auto-uses query_id from ctx.runtime.query_id.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'result': {}} + + ctx = create_mock_context( + run_id='run_auto_query', + query_id=200, # This should be auto-used + tools=[{'tool_name': 'test_tool'}] + ) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + # Call call_tool without explicit query_id parameter (new signature) + await proxy.call_tool('test_tool', {'param': 'value'}) + + call_args = mock_handler.call_action_mock.call_args + data = call_args[0][1] + + # query_id should be auto-filled from ctx.runtime.query_id + assert data['query_id'] == 200 + + +class TestAgentRunAPIProxyStoragePermission: + """Tests for storage permission validation in AgentRunAPIProxy.""" + + @pytest.mark.anyio + async def test_plugin_storage_when_disabled_raises_error(self): + """set_plugin_storage raises PermissionDeniedError when storage is disabled.""" + mock_handler = MockHandler() + + ctx = create_mock_context( + run_id='run_storage', + storage={'plugin_storage': False, 'workspace_storage': False} # Disabled + ) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + with pytest.raises(PermissionDeniedError) as exc_info: + await proxy.set_plugin_storage('key', b'value') + + assert 'plugin storage' in str(exc_info.value).lower() + assert 'not authorized' in str(exc_info.value) + + @pytest.mark.anyio + async def test_plugin_storage_when_enabled_succeeds(self): + """set_plugin_storage succeeds when storage is enabled.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {} + + ctx = create_mock_context( + run_id='run_storage', + storage={'plugin_storage': True, 'workspace_storage': False} # Plugin storage enabled + ) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.set_plugin_storage('key', b'value') + + # Should have called the action + assert mock_handler.call_action_mock.called + + @pytest.mark.anyio + async def test_workspace_storage_when_disabled_raises_error(self): + """set_workspace_storage raises PermissionDeniedError when storage is disabled.""" + mock_handler = MockHandler() + + ctx = create_mock_context( + run_id='run_storage', + storage={'plugin_storage': False, 'workspace_storage': False} # Disabled + ) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + with pytest.raises(PermissionDeniedError) as exc_info: + await proxy.set_workspace_storage('key', b'value') + + assert 'workspace storage' in str(exc_info.value).lower() + assert 'not authorized' in str(exc_info.value) + + @pytest.mark.anyio + async def test_workspace_storage_when_enabled_succeeds(self): + """set_workspace_storage succeeds when storage is enabled.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {} + + ctx = create_mock_context( + run_id='run_storage', + storage={'plugin_storage': False, 'workspace_storage': True} # Workspace storage enabled + ) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.set_workspace_storage('key', b'value') + + # Should have called the action + assert mock_handler.call_action_mock.called + + @pytest.mark.anyio + async def test_get_plugin_storage_when_disabled_raises_error(self): + """get_plugin_storage raises PermissionDeniedError when storage is disabled.""" + mock_handler = MockHandler() + + ctx = create_mock_context( + run_id='run_storage', + storage={'plugin_storage': False, 'workspace_storage': False} + ) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + with pytest.raises(PermissionDeniedError) as exc_info: + await proxy.get_plugin_storage('key') + + assert 'plugin storage' in str(exc_info.value).lower() + + +class TestAgentRunAPIProxyFileAccess: + """Tests for file access validation in AgentRunAPIProxy.""" + + @pytest.mark.anyio + async def test_get_file_with_authorized_file(self): + """get_file succeeds when file is in ctx.resources.files.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'file_base64': 'SGVsbG8='} + + ctx = create_mock_context( + run_id='run_file_test', + files=[{'file_id': 'file_001'}] + ) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + result = await proxy.get_file('file_001') + + assert result == b'Hello' # base64 decoded + + @pytest.mark.anyio + async def test_get_file_with_unauthorized_file_raises_error(self): + """get_file raises PermissionDeniedError when file is NOT authorized.""" + mock_handler = MockHandler() + + ctx = create_mock_context( + run_id='run_unauth', + files=[{'file_id': 'file_001'}] # Only file_001 is authorized + ) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + with pytest.raises(PermissionDeniedError) as exc_info: + await proxy.get_file('file_999') # file_999 NOT authorized + + assert 'file_999' in str(exc_info.value) + assert 'not authorized' in str(exc_info.value) + + +class TestAgentRunAPIProxyTimeoutValues: + """Tests for timeout values in AgentRunAPIProxy.""" + + @pytest.mark.anyio + async def test_invoke_llm_default_timeout(self): + """invoke_llm default timeout is 120 seconds.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'message': {'role': 'assistant', 'content': 'Hello'}} + + ctx = create_mock_context(models=[{'model_id': 'model_001'}]) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.invoke_llm('model_001', [Message(role='user', content='Hello')]) + + call_args = mock_handler.call_action_mock.call_args + timeout = call_args[0][2] + + assert timeout == 120.0 + + @pytest.mark.anyio + async def test_invoke_llm_custom_timeout(self): + """invoke_llm custom timeout is passed correctly.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'message': {'role': 'assistant', 'content': 'Hello'}} + + ctx = create_mock_context(models=[{'model_id': 'model_001'}]) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.invoke_llm('model_001', [Message(role='user', content='Hello')], timeout=60.0) + + call_args = mock_handler.call_action_mock.call_args + timeout = call_args[0][2] + + assert timeout == 60.0 + + @pytest.mark.anyio + async def test_call_tool_timeout_is_180(self): + """call_tool timeout is 180 seconds.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'result': {}} + + ctx = create_mock_context(tools=[{'tool_name': 'test_tool'}]) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.call_tool('test_tool', {'param': 'value'}) + + call_args = mock_handler.call_action_mock.call_args + timeout = call_args[0][2] + + assert timeout == 180 + + @pytest.mark.anyio + async def test_retrieve_knowledge_timeout_is_30(self): + """retrieve_knowledge timeout is 30 seconds.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'results': []} + + ctx = create_mock_context(knowledge_bases=[{'kb_id': 'kb_001'}]) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.retrieve_knowledge('kb_001', 'search query') + + call_args = mock_handler.call_action_mock.call_args + timeout = call_args[0][2] + + assert timeout == 30 + + +class TestAgentRunAPIProxyActionEnumCorrectness: + """Tests to ensure correct action enum usage.""" + + def test_retrieve_knowledge_uses_retrieve_knowledge_base_action(self): + """retrieve_knowledge uses RETRIEVE_KNOWLEDGE_BASE (not unrestricted RETRIEVE_KNOWLEDGE).""" + unrestricted_action = PluginToRuntimeAction.RETRIEVE_KNOWLEDGE + restricted_action = PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE + + assert unrestricted_action.value == 'retrieve_knowledge' + assert restricted_action.value == 'retrieve_knowledge_base' + assert unrestricted_action != restricted_action + + @pytest.mark.anyio + async def test_retrieve_knowledge_sends_correct_action(self): + """retrieve_knowledge sends RETRIEVE_KNOWLEDGE_BASE action.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'results': []} + + ctx = create_mock_context(knowledge_bases=[{'kb_id': 'kb_001'}]) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.retrieve_knowledge('kb_001', 'search query') + + call_args = mock_handler.call_action_mock.call_args + action = call_args[0][0] + + assert action == PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE + + +class TestAgentRunAPIProxyFieldConsistency: + """Tests for field name consistency between SDK and Host handler.""" + + @pytest.mark.anyio + async def test_invoke_llm_sends_correct_fields(self): + """INVOKE_LLM: SDK fields match Host handler expectations.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'message': {'role': 'assistant', 'content': 'Hello'}} + + ctx = create_mock_context(models=[{'model_id': 'model_001'}]) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.invoke_llm('model_001', [Message(role='user', content='Hello')]) + + call_args = mock_handler.call_action_mock.call_args + data = call_args[0][1] + + # Host handler expects these fields + assert 'run_id' in data + assert 'llm_model_uuid' in data + assert 'messages' in data + assert 'funcs' in data + assert 'extra_args' in data + assert 'timeout' in data + + @pytest.mark.anyio + async def test_call_tool_sends_correct_fields(self): + """CALL_TOOL: SDK fields match Host handler expectations.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'result': {}} + + ctx = create_mock_context(query_id=123, tools=[{'tool_name': 'test_tool'}]) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.call_tool('test_tool', {'param': 'value'}) + + call_args = mock_handler.call_action_mock.call_args + data = call_args[0][1] + + assert 'run_id' in data + assert 'tool_name' in data + assert 'parameters' in data + assert 'query_id' in data + + @pytest.mark.anyio + async def test_retrieve_knowledge_sends_correct_fields(self): + """RETRIEVE_KNOWLEDGE_BASE: SDK fields match Host handler expectations.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'results': []} + + ctx = create_mock_context(query_id=456, knowledge_bases=[{'kb_id': 'kb_001'}]) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.retrieve_knowledge('kb_001', 'search query', top_k=5, filters={'cat': 'tech'}) + + call_args = mock_handler.call_action_mock.call_args + data = call_args[0][1] + + assert 'run_id' in data + assert 'query_id' in data + assert 'kb_id' in data + assert 'query_text' in data + assert 'top_k' in data + assert 'filters' in data \ No newline at end of file diff --git a/tests/runtime/plugin/test_mgr_agent_runner.py b/tests/runtime/plugin/test_mgr_agent_runner.py index f3a02999..db145041 100644 --- a/tests/runtime/plugin/test_mgr_agent_runner.py +++ b/tests/runtime/plugin/test_mgr_agent_runner.py @@ -135,10 +135,10 @@ async def mock_call_action_generator(action, data, timeout=300): for idx, (rname, _) in enumerate(runner_components): if rname == runner_name and idx < len(mock_handler_responses): for resp in mock_handler_responses[idx]: - yield {"ok": True, "data": resp} + yield resp # Yield response data directly (matches real call_action_generator) return # No matching responses found - yield {"ok": False, "message": f"No mock responses for {runner_name}"} + yield {"type": "run.failed", "data": {"error": f"No mock responses for {runner_name}", "code": "runner.mock_error"}} mock_handler = Mock() mock_handler.call_action_generator = mock_call_action_generator From f3239c3a0a5e3114ebe342f93fe4ac3b01e1d7ca Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Wed, 13 May 2026 10:24:27 +0800 Subject: [PATCH 04/16] feat(agent-runner): enhance AgentRunAPIProxy and plugin handler - Improve AgentRunAPIProxy with state scope support (conversation/session/run) - Enhance plugin handler for agent run operations - Add better error handling and logging --- .../api/proxies/agent_run_api.py | 142 +++++++++++++++--- .../runtime/io/handlers/plugin.py | 110 ++++++++++++-- 2 files changed, 224 insertions(+), 28 deletions(-) diff --git a/src/langbot_plugin/api/proxies/agent_run_api.py b/src/langbot_plugin/api/proxies/agent_run_api.py index 87be513b..d2d235c1 100644 --- a/src/langbot_plugin/api/proxies/agent_run_api.py +++ b/src/langbot_plugin/api/proxies/agent_run_api.py @@ -2,15 +2,18 @@ This proxy provides a restricted API for AgentRunner execution, with all capabilities explicitly authorized through ctx.resources. + +Uses composition instead of inheritance to ensure restricted APIs +are NOT exposed (hasattr returns False). """ from __future__ import annotations +import base64 from typing import Any from langbot_plugin.runtime.io.handler import Handler from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction -from langbot_plugin.api.proxies.langbot_api import LangBotAPIProxy from langbot_plugin.api.entities.builtin.provider import message as provider_message from langbot_plugin.api.entities.builtin.resource import tool as resource_tool from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext @@ -22,11 +25,11 @@ class PermissionDeniedError(Exception): pass -class AgentRunAPIProxy(LangBotAPIProxy): +class AgentRunAPIProxy: """Restricted API proxy for AgentRunner execution. - Inherits from LangBotAPIProxy and adds permission validation. - All resource access is validated against AgentRunContext.resources. + Uses COMPOSITION instead of inheritance to ensure restricted APIs + are NOT accessible (hasattr returns False for unauthorized methods). Authorized APIs (validated against ctx.resources): - invoke_llm() / invoke_llm_stream(): requires model in ctx.resources.models @@ -41,14 +44,23 @@ class AgentRunAPIProxy(LangBotAPIProxy): - get_allowed_tools(): returns ctx.resources.tools - get_allowed_knowledge_bases(): returns ctx.resources.knowledge_bases - get_allowed_files(): returns ctx.resources.files + - get_langbot_version(): no authorization needed Not available (platform actions, use AgentRunResult.action_requested instead): - get_bots() / get_bot_info() / send_message() + - list_tools() / get_tool_detail() + - list_knowledge_bases() + - get_llm_models() + - vector_upsert() / vector_search() / invoke_embedding() + - get_config_file() """ ctx: AgentRunContext """Agent run context containing run_id, resources, and runtime info.""" + plugin_runtime_handler: Handler + """Handler for calling LangBot runtime actions.""" + # Pre-computed allowed IDs for efficient O(1) validation _allowed_model_ids: frozenset[str] _allowed_tool_names: frozenset[str] @@ -56,8 +68,8 @@ class AgentRunAPIProxy(LangBotAPIProxy): _allowed_file_ids: frozenset[str] def __init__(self, ctx: AgentRunContext, plugin_runtime_handler: Handler): - super().__init__(plugin_runtime_handler) self.ctx = ctx + self.plugin_runtime_handler = plugin_runtime_handler # Pre-compute allowed IDs for efficient validation self._allowed_model_ids = frozenset(m.model_id for m in ctx.resources.models) self._allowed_tool_names = frozenset(t.tool_name for t in ctx.resources.tools) @@ -130,7 +142,7 @@ def _validate_workspace_storage_access(self) -> None: if not self.ctx.resources.storage.workspace_storage: raise PermissionDeniedError("Workspace storage is not authorized.") - # ================= LLM APIs (override to add validation + run_id) ================= + # ================= LLM APIs ================= async def invoke_llm( self, @@ -183,7 +195,7 @@ async def invoke_llm_stream( ): yield provider_message.MessageChunk.model_validate(chunk_data["chunk"]) - # ================= Tool API (different signature from parent) ================= + # ================= Tool API ================= async def call_tool( self, @@ -212,7 +224,7 @@ async def call_tool( ) )["result"] - # ================= Knowledge Base API (override to add validation) ================= + # ================= Knowledge Base API ================= async def retrieve_knowledge( self, @@ -242,43 +254,135 @@ async def retrieve_knowledge( ) )["results"] - # ================= Storage APIs (override to add validation) ================= + # ================= Storage APIs ================= async def set_plugin_storage(self, key: str, value: bytes) -> None: + """Set a plugin storage value with permission validation.""" self._validate_plugin_storage_access() - await super().set_plugin_storage(key, value) + await self.plugin_runtime_handler.call_action( + PluginToRuntimeAction.SET_PLUGIN_STORAGE, + { + "key": key, + "value_base64": base64.b64encode(value).decode("utf-8"), + "run_id": self.run_id, # Host-side validation + }, + ) async def get_plugin_storage(self, key: str) -> bytes: + """Get a plugin storage value with permission validation.""" self._validate_plugin_storage_access() - return await super().get_plugin_storage(key) + resp = ( + await self.plugin_runtime_handler.call_action( + PluginToRuntimeAction.GET_PLUGIN_STORAGE, + { + "key": key, + "run_id": self.run_id, # Host-side validation + }, + ) + )["value_base64"] + return base64.b64decode(resp) async def get_plugin_storage_keys(self) -> list[str]: + """Get all plugin storage keys with permission validation.""" self._validate_plugin_storage_access() - return await super().get_plugin_storage_keys() + return ( + await self.plugin_runtime_handler.call_action( + PluginToRuntimeAction.GET_PLUGIN_STORAGE_KEYS, + { + "run_id": self.run_id, # Host-side validation + }, + ) + )["keys"] async def delete_plugin_storage(self, key: str) -> None: + """Delete a plugin storage value with permission validation.""" self._validate_plugin_storage_access() - await super().delete_plugin_storage(key) + await self.plugin_runtime_handler.call_action( + PluginToRuntimeAction.DELETE_PLUGIN_STORAGE, + { + "key": key, + "run_id": self.run_id, # Host-side validation + }, + ) async def set_workspace_storage(self, key: str, value: bytes) -> None: + """Set a workspace storage value with permission validation.""" self._validate_workspace_storage_access() - await super().set_workspace_storage(key, value) + await self.plugin_runtime_handler.call_action( + PluginToRuntimeAction.SET_WORKSPACE_STORAGE, + { + "key": key, + "value_base64": base64.b64encode(value).decode("utf-8"), + "run_id": self.run_id, # Host-side validation + }, + ) async def get_workspace_storage(self, key: str) -> bytes: + """Get a workspace storage value with permission validation.""" self._validate_workspace_storage_access() - return await super().get_workspace_storage(key) + resp = ( + await self.plugin_runtime_handler.call_action( + PluginToRuntimeAction.GET_WORKSPACE_STORAGE, + { + "key": key, + "run_id": self.run_id, # Host-side validation + }, + ) + )["value_base64"] + return base64.b64decode(resp) async def get_workspace_storage_keys(self) -> list[str]: + """Get all workspace storage keys with permission validation.""" self._validate_workspace_storage_access() - return await super().get_workspace_storage_keys() + return ( + await self.plugin_runtime_handler.call_action( + PluginToRuntimeAction.GET_WORKSPACE_STORAGE_KEYS, + { + "run_id": self.run_id, # Host-side validation + }, + ) + )["keys"] async def delete_workspace_storage(self, key: str) -> None: + """Delete a workspace storage value with permission validation.""" self._validate_workspace_storage_access() - await super().delete_workspace_storage(key) + await self.plugin_runtime_handler.call_action( + PluginToRuntimeAction.DELETE_WORKSPACE_STORAGE, + { + "key": key, + "run_id": self.run_id, # Host-side validation + }, + ) # ================= File API ================= async def get_file(self, file_key: str) -> bytes: - """Get a file with permission validation.""" + """Get a file with permission validation. + + Args: + file_key: The file key from ctx.resources.files + + Returns: + The file content as bytes + """ self._validate_file_access(file_key) - return await super().get_config_file(file_key) \ No newline at end of file + resp = ( + await self.plugin_runtime_handler.call_action( + PluginToRuntimeAction.GET_CONFIG_FILE, + { + "file_key": file_key, + "run_id": self.run_id, # Host-side validation + }, + ) + )["file_base64"] + return base64.b64decode(resp) + + # ================= Version API (no authorization needed) ================= + + async def get_langbot_version(self) -> str: + """Get the LangBot version (no authorization needed).""" + return ( + await self.plugin_runtime_handler.call_action( + PluginToRuntimeAction.GET_LANGBOT_VERSION, {} + ) + )["version"] \ No newline at end of file diff --git a/src/langbot_plugin/runtime/io/handlers/plugin.py b/src/langbot_plugin/runtime/io/handlers/plugin.py index 5ee0c873..06fd905f 100644 --- a/src/langbot_plugin/runtime/io/handlers/plugin.py +++ b/src/langbot_plugin/runtime/io/handlers/plugin.py @@ -20,6 +20,17 @@ LONG_RUNNING_OPERATION_TIMEOUT = 180.0 +def _get_caller_plugin_identity(handler_instance: "PluginConnectionHandler") -> str | None: + """Get the caller plugin identity (author/name) from the handler instance. + + Returns None if the handler is not associated with a registered plugin. + """ + for plugin_container in handler_instance.context.plugin_mgr.plugins: + if plugin_container._runtime_plugin_handler == handler_instance: + return f"{plugin_container.manifest.metadata.author}/{plugin_container.manifest.metadata.name}" + return None + + class PluginConnectionHandler(handler.Handler): """The handler for plugin connection.""" @@ -212,6 +223,11 @@ async def invoke_llm(data: dict[str, Any]) -> handler.ActionResponse: if not isinstance(timeout, (int, float)) or timeout <= 0: timeout = 120.0 + # Inject caller_plugin_identity for Host-side AgentRunner validation + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + result = await self.context.control_handler.call_action( PluginToRuntimeAction.INVOKE_LLM, { @@ -230,6 +246,11 @@ async def invoke_llm_stream( if not isinstance(timeout, (int, float)) or timeout <= 0: timeout = 120.0 + # Inject caller_plugin_identity for Host-side AgentRunner validation + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + async for chunk in self.context.control_handler.call_action_generator( PluginToRuntimeAction.INVOKE_LLM_STREAM, { @@ -331,6 +352,11 @@ async def list_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse: @self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE) async def retrieve_knowledge(data: dict[str, Any]) -> handler.ActionResponse: + # Inject caller_plugin_identity for Host-side AgentRunner validation + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + result = await self.context.control_handler.call_action( PluginToRuntimeAction.RETRIEVE_KNOWLEDGE, data, @@ -352,6 +378,11 @@ async def list_pipeline_knowledge_bases( async def retrieve_knowledge_base( data: dict[str, Any], ) -> handler.ActionResponse: + # Inject caller_plugin_identity for Host-side AgentRunner validation + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + result = await self.context.control_handler.call_action( PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE, data, @@ -385,6 +416,7 @@ async def invoke_parser(data: dict[str, Any]) -> handler.ActionResponse: async def set_plugin_storage(data: dict[str, Any]) -> handler.ActionResponse: data["owner_type"] = "plugin" + caller_identity = _get_caller_plugin_identity(self) for plugin_container in self.context.plugin_mgr.plugins: if plugin_container._runtime_plugin_handler == self: data["owner"] = ( @@ -392,6 +424,10 @@ async def set_plugin_storage(data: dict[str, Any]) -> handler.ActionResponse: ) break + # Attach caller_plugin_identity for Host-side AgentRunner validation + if caller_identity: + data["caller_plugin_identity"] = caller_identity + result = await self.context.control_handler.call_action( RuntimeToLangBotAction.SET_BINARY_STORAGE, { @@ -404,6 +440,7 @@ async def set_plugin_storage(data: dict[str, Any]) -> handler.ActionResponse: async def get_plugin_storage(data: dict[str, Any]) -> handler.ActionResponse: data["owner_type"] = "plugin" + caller_identity = _get_caller_plugin_identity(self) for plugin_container in self.context.plugin_mgr.plugins: if plugin_container._runtime_plugin_handler == self: data["owner"] = ( @@ -411,6 +448,10 @@ async def get_plugin_storage(data: dict[str, Any]) -> handler.ActionResponse: ) break + # Attach caller_plugin_identity for Host-side AgentRunner validation + if caller_identity: + data["caller_plugin_identity"] = caller_identity + result = await self.context.control_handler.call_action( RuntimeToLangBotAction.GET_BINARY_STORAGE, { @@ -425,6 +466,7 @@ async def get_plugin_storage_keys( ) -> handler.ActionResponse: data["owner_type"] = "plugin" + caller_identity = _get_caller_plugin_identity(self) for plugin_container in self.context.plugin_mgr.plugins: if plugin_container._runtime_plugin_handler == self: data["owner"] = ( @@ -432,6 +474,10 @@ async def get_plugin_storage_keys( ) break + # Attach caller_plugin_identity for Host-side AgentRunner validation + if caller_identity: + data["caller_plugin_identity"] = caller_identity + result = await self.context.control_handler.call_action( RuntimeToLangBotAction.GET_BINARY_STORAGE_KEYS, { @@ -444,6 +490,7 @@ async def get_plugin_storage_keys( async def delete_plugin_storage(data: dict[str, Any]) -> handler.ActionResponse: data["owner_type"] = "plugin" + caller_identity = _get_caller_plugin_identity(self) for plugin_container in self.context.plugin_mgr.plugins: if plugin_container._runtime_plugin_handler == self: data["owner"] = ( @@ -451,6 +498,10 @@ async def delete_plugin_storage(data: dict[str, Any]) -> handler.ActionResponse: ) break + # Attach caller_plugin_identity for Host-side AgentRunner validation + if caller_identity: + data["caller_plugin_identity"] = caller_identity + result = await self.context.control_handler.call_action( RuntimeToLangBotAction.DELETE_BINARY_STORAGE, { @@ -464,6 +515,11 @@ async def set_workspace_storage(data: dict[str, Any]) -> handler.ActionResponse: data["owner_type"] = "workspace" data["owner"] = "default" + # Attach caller_plugin_identity for Host-side AgentRunner validation + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + result = await self.context.control_handler.call_action( RuntimeToLangBotAction.SET_BINARY_STORAGE, { @@ -477,6 +533,11 @@ async def get_workspace_storage(data: dict[str, Any]) -> handler.ActionResponse: data["owner_type"] = "workspace" data["owner"] = "default" + # Attach caller_plugin_identity for Host-side AgentRunner validation + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + result = await self.context.control_handler.call_action( RuntimeToLangBotAction.GET_BINARY_STORAGE, { @@ -492,6 +553,11 @@ async def get_workspace_storage_keys( data["owner_type"] = "workspace" data["owner"] = "default" + # Attach caller_plugin_identity for Host-side AgentRunner validation + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + result = await self.context.control_handler.call_action( RuntimeToLangBotAction.GET_BINARY_STORAGE_KEYS, { @@ -507,6 +573,11 @@ async def delete_workspace_storage( data["owner_type"] = "workspace" data["owner"] = "default" + # Attach caller_plugin_identity for Host-side AgentRunner validation + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + result = await self.context.control_handler.call_action( RuntimeToLangBotAction.DELETE_BINARY_STORAGE, { @@ -518,11 +589,18 @@ async def delete_workspace_storage( @self.action(PluginToRuntimeAction.GET_CONFIG_FILE) async def get_config_file(data: dict[str, Any]) -> handler.ActionResponse: """Get a config file by file key""" + # Attach caller_plugin_identity for Host-side AgentRunner validation + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + # Forward the request to LangBot result = await self.context.control_handler.call_action( RuntimeToLangBotAction.GET_CONFIG_FILE, { "file_key": data["file_key"], + "run_id": data.get("run_id"), # Pass run_id for AgentRunner validation + "caller_plugin_identity": data.get("caller_plugin_identity"), }, ) return handler.ActionResponse.success(result) @@ -556,16 +634,30 @@ async def get_tool_detail(data: dict[str, Any]) -> handler.ActionResponse: @self.action(PluginToRuntimeAction.CALL_TOOL) async def call_tool_from_plugin(data: dict[str, Any]) -> handler.ActionResponse: - tool_name = data["tool_name"] - tool_parameters = data["tool_parameters"] - session = data["session"] - query_id = data["query_id"] - resp = await self.context.plugin_mgr.call_tool( - tool_name, tool_parameters, session, query_id - ) - return handler.ActionResponse.success( - {"tool_response": resp} + """Forward tool call to LangBot Host with caller_plugin_identity injection. + + Supports both 'parameters' (AgentRunAPIProxy) and 'tool_parameters' (LangBotAPIProxy). + """ + # Support both 'parameters' (new) and 'tool_parameters' (old) for backward compatibility + if "parameters" in data and "tool_parameters" not in data: + # New AgentRunAPIProxy format - use 'parameters' as 'tool_parameters' for Host + data["tool_parameters"] = data["parameters"] + elif "tool_parameters" in data: + # Old LangBotAPIProxy format - already has 'tool_parameters' + pass + + # Inject caller_plugin_identity for Host-side AgentRunner validation + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + + # Forward to LangBot Host via control_handler (enables Host validation) + result = await self.context.control_handler.call_action( + PluginToRuntimeAction.CALL_TOOL, + data, + timeout=LONG_RUNNING_OPERATION_TIMEOUT, ) + return handler.ActionResponse.success(result) @self.action(PluginToRuntimeAction.LIST_PLUGINS_MANIFEST) async def list_plugins_manifest(data: dict[str, Any]) -> handler.ActionResponse: From 480e5ef90cfbef4418cda0cda71d497cdd3f2dad Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Wed, 13 May 2026 10:31:19 +0800 Subject: [PATCH 05/16] docs: update PHASE0_INTEGRATION_LOG with Phase 3 completion Add record of all 7 runner plugins migration completed on 2026-05-13 --- .../PHASE0_INTEGRATION_LOG.md | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/agent-runner-pluginization/PHASE0_INTEGRATION_LOG.md b/docs/agent-runner-pluginization/PHASE0_INTEGRATION_LOG.md index f3430ddd..2d743549 100644 --- a/docs/agent-runner-pluginization/PHASE0_INTEGRATION_LOG.md +++ b/docs/agent-runner-pluginization/PHASE0_INTEGRATION_LOG.md @@ -91,4 +91,35 @@ LangBot + SDK + runner repo Phase 0 聃调通过。 - 前端保存新格式 `ai.runner.id` / `ai.runner_config` - 持久化 migration - 模板 `ai.yaml/default-pipeline-config.json` 更新 - - proxy action 二次权限校验 \ No newline at end of file + - proxy action 二次权限校验 + +--- + +## Phase 3 完成记录 (2026-05-13) + +### 迁移完成 + +所有 7 个官方 runner 插件已完成迁移: + +| 插件 | 状态 | 实现位置 | +|------|------|----------| +| local-agent | ✅ | `langbot-local-agent/` (独立仓库) | +| dify-agent | ✅ | `langbot-agent-runner/dify-agent/` | +| n8n-agent | ✅ | `langbot-agent-runner/n8n-agent/` | +| coze-agent | ✅ | `langbot-agent-runner/coze-agent/` | +| dashscope-agent | ✅ | `langbot-agent-runner/dashscope-agent/` | +| langflow-agent | ✅ | `langbot-agent-runner/langflow-agent/` | +| tbox-agent | ✅ | `langbot-agent-runner/tbox-agent/` | + +### 新增文件 + +每个插件包含: +- `components/agent_runner/default.py` - AgentRunner 实现 +- `components/agent_runner/default.yaml` - 组件 manifest +- `pkg/*_client.py` - 平台 API 客户端 +- `main.py` - 插件入口 + +### LangBot 侧变更 + +- 旧 runner 文件标记为 legacy(添加 DEPRECATED docstring) +- 新增 `PROGRESS.md` 跟踪实现进度 \ No newline at end of file From 15ad938a54a964dd40c13ca586bc60d9722e244f Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Wed, 13 May 2026 10:36:47 +0800 Subject: [PATCH 06/16] feat(agent-runner): add INVOKE_RERANK action and API proxy method - Add INVOKE_RERANK to PluginToRuntimeAction enum - Add invoke_rerank() method to AgentRunAPIProxy - Support reranking documents with relevance scores --- .../api/proxies/agent_run_api.py | 53 ++++++++++++++++++- .../entities/io/actions/enums.py | 1 + 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/langbot_plugin/api/proxies/agent_run_api.py b/src/langbot_plugin/api/proxies/agent_run_api.py index d2d235c1..1900de13 100644 --- a/src/langbot_plugin/api/proxies/agent_run_api.py +++ b/src/langbot_plugin/api/proxies/agent_run_api.py @@ -385,4 +385,55 @@ async def get_langbot_version(self) -> str: await self.plugin_runtime_handler.call_action( PluginToRuntimeAction.GET_LANGBOT_VERSION, {} ) - )["version"] \ No newline at end of file + )["version"] + + # ================= Rerank API ================= + + async def invoke_rerank( + self, + rerank_model_uuid: str, + query: str, + documents: list[str], + top_k: int | None = None, + timeout: float = 30.0, + ) -> list[dict[str, Any]]: + """Invoke a rerank model to re-score documents. + + Args: + rerank_model_uuid: UUID of the rerank model + query: The query text for reranking + documents: List of document texts to rerank + top_k: Optional number of top results to return + timeout: Request timeout in seconds + + Returns: + List of dicts with 'index' and 'relevance_score' keys, + sorted by relevance_score descending + + Example: + results = await api.invoke_rerank( + rerank_model_uuid="xxx-xxx", + query="What is machine learning?", + documents=["Doc 1 text", "Doc 2 text", "Doc 3 text"], + top_k=5, + ) + # results = [ + # {"index": 2, "relevance_score": 0.95}, + # {"index": 0, "relevance_score": 0.82}, + # ... + # ] + """ + # Note: Rerank model access is validated by LangBot host + # The model must be in ctx.resources.models with rerank capability + resp = await self.plugin_runtime_handler.call_action( + PluginToRuntimeAction.INVOKE_RERANK, + { + "run_id": self.run_id, + "rerank_model_uuid": rerank_model_uuid, + "query": query, + "documents": documents, + "top_k": top_k, + }, + timeout=timeout, + ) + return resp.get("results", []) \ No newline at end of file diff --git a/src/langbot_plugin/entities/io/actions/enums.py b/src/langbot_plugin/entities/io/actions/enums.py index d6a1a21c..a92d4696 100644 --- a/src/langbot_plugin/entities/io/actions/enums.py +++ b/src/langbot_plugin/entities/io/actions/enums.py @@ -61,6 +61,7 @@ class PluginToRuntimeAction(ActionType): CALL_TOOL = "call_tool" INVOKE_EMBEDDING = "invoke_embedding" + INVOKE_RERANK = "invoke_rerank" VECTOR_UPSERT = "vector_upsert" VECTOR_SEARCH = "vector_search" VECTOR_DELETE = "vector_delete" From 8bd56d255963da7ad3f7cc1d0132170a7786c980 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Wed, 13 May 2026 11:58:21 +0800 Subject: [PATCH 07/16] refactor(agent-run-api): use composition+delegation pattern Reduces code duplication by composing LangBotAPIProxy instead of directly calling plugin_runtime_handler. Adds get_tool_detail() for LLM function calling support. Changes: - Compose LangBotAPIProxy for delegated API calls - Add permission validation before each delegation - Add get_tool_detail() method for tool schema fetching - Reduce from ~460 lines to ~350 lines --- .../api/proxies/agent_run_api.py | 251 ++++++------------ 1 file changed, 83 insertions(+), 168 deletions(-) diff --git a/src/langbot_plugin/api/proxies/agent_run_api.py b/src/langbot_plugin/api/proxies/agent_run_api.py index 1900de13..4e7d9254 100644 --- a/src/langbot_plugin/api/proxies/agent_run_api.py +++ b/src/langbot_plugin/api/proxies/agent_run_api.py @@ -3,13 +3,14 @@ This proxy provides a restricted API for AgentRunner execution, with all capabilities explicitly authorized through ctx.resources. -Uses composition instead of inheritance to ensure restricted APIs -are NOT exposed (hasattr returns False). +Uses composition + delegation pattern: +- Composes LangBotAPIProxy for actual API calls (reduces code duplication) +- Adds permission validation before each delegated call +- Only exposes APIs that are authorized for the current run """ from __future__ import annotations -import base64 from typing import Any from langbot_plugin.runtime.io.handler import Handler @@ -17,6 +18,7 @@ from langbot_plugin.api.entities.builtin.provider import message as provider_message from langbot_plugin.api.entities.builtin.resource import tool as resource_tool from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext +from langbot_plugin.api.proxies.langbot_api import LangBotAPIProxy class PermissionDeniedError(Exception): @@ -28,12 +30,14 @@ class PermissionDeniedError(Exception): class AgentRunAPIProxy: """Restricted API proxy for AgentRunner execution. - Uses COMPOSITION instead of inheritance to ensure restricted APIs - are NOT accessible (hasattr returns False for unauthorized methods). + Uses COMPOSITION + DELEGATION to LangBotAPIProxy: + - Validates permissions before delegating calls + - Reduces code duplication by reusing LangBotAPIProxy implementation + - Only exposes authorized APIs (hasattr returns False for unauthorized methods) Authorized APIs (validated against ctx.resources): - invoke_llm() / invoke_llm_stream(): requires model in ctx.resources.models - - call_tool(): requires tool_name in ctx.resources.tools + - get_tool_detail() / call_tool(): requires tool_name in ctx.resources.tools - retrieve_knowledge(): requires kb_id in ctx.resources.knowledge_bases - plugin_storage: requires ctx.resources.storage.plugin_storage=True - workspace_storage: requires ctx.resources.storage.workspace_storage=True @@ -44,22 +48,21 @@ class AgentRunAPIProxy: - get_allowed_tools(): returns ctx.resources.tools - get_allowed_knowledge_bases(): returns ctx.resources.knowledge_bases - get_allowed_files(): returns ctx.resources.files - - get_langbot_version(): no authorization needed + + Additional APIs (AgentRunner-specific): + - invoke_rerank(): requires rerank model authorization (validated by host) Not available (platform actions, use AgentRunResult.action_requested instead): - get_bots() / get_bot_info() / send_message() - - list_tools() / get_tool_detail() - - list_knowledge_bases() - - get_llm_models() + - list_tools() / list_knowledge_bases() / get_llm_models() - vector_upsert() / vector_search() / invoke_embedding() - - get_config_file() """ ctx: AgentRunContext """Agent run context containing run_id, resources, and runtime info.""" - plugin_runtime_handler: Handler - """Handler for calling LangBot runtime actions.""" + _api: LangBotAPIProxy + """Unrestricted API proxy for delegation (composition).""" # Pre-computed allowed IDs for efficient O(1) validation _allowed_model_ids: frozenset[str] @@ -69,7 +72,7 @@ class AgentRunAPIProxy: def __init__(self, ctx: AgentRunContext, plugin_runtime_handler: Handler): self.ctx = ctx - self.plugin_runtime_handler = plugin_runtime_handler + self._api = LangBotAPIProxy(plugin_runtime_handler) # Pre-compute allowed IDs for efficient validation self._allowed_model_ids = frozenset(m.model_id for m in ctx.resources.models) self._allowed_tool_names = frozenset(t.tool_name for t in ctx.resources.tools) @@ -142,7 +145,7 @@ def _validate_workspace_storage_access(self) -> None: if not self.ctx.resources.storage.workspace_storage: raise PermissionDeniedError("Workspace storage is not authorized.") - # ================= LLM APIs ================= + # ================= LLM APIs (delegated with validation) ================= async def invoke_llm( self, @@ -152,26 +155,15 @@ async def invoke_llm( extra_args: dict[str, Any] = {}, timeout: float | None = None, ) -> provider_message.Message: - """Invoke an LLM model with permission validation and run_id.""" + """Invoke an LLM model with permission validation.""" self._validate_model_access(llm_model_uuid) - - effective_timeout = timeout if timeout is not None else 120.0 - resp = ( - await self.plugin_runtime_handler.call_action( - PluginToRuntimeAction.INVOKE_LLM, - { - "run_id": self.run_id, - "llm_model_uuid": llm_model_uuid, - "messages": [m.model_dump() for m in messages], - "funcs": [f.model_dump() for f in funcs], - "extra_args": extra_args, - "timeout": effective_timeout, - }, - timeout=effective_timeout, - ) - )["message"] - - return provider_message.Message.model_validate(resp) + return await self._api.invoke_llm( + llm_model_uuid=llm_model_uuid, + messages=messages, + funcs=funcs, + extra_args=extra_args, + timeout=timeout, + ) async def invoke_llm_stream( self, @@ -180,22 +172,35 @@ async def invoke_llm_stream( funcs: list[resource_tool.LLMTool] = [], extra_args: dict[str, Any] = {}, ): - """Invoke an LLM model with streaming, permission validation and run_id.""" + """Invoke an LLM model with streaming, permission validation.""" self._validate_model_access(llm_model_uuid) - - async for chunk_data in self.plugin_runtime_handler.call_action_generator( - PluginToRuntimeAction.INVOKE_LLM_STREAM, - { - "run_id": self.run_id, - "llm_model_uuid": llm_model_uuid, - "messages": [m.model_dump() for m in messages], - "funcs": [f.model_dump() for f in funcs], - "extra_args": extra_args, - }, + async for chunk in self._api.invoke_llm_stream( + llm_model_uuid=llm_model_uuid, + messages=messages, + funcs=funcs, + extra_args=extra_args, ): - yield provider_message.MessageChunk.model_validate(chunk_data["chunk"]) + yield chunk + + # ================= Tool APIs (delegated with validation) ================= - # ================= Tool API ================= + async def get_tool_detail(self, tool_name: str) -> dict[str, Any]: + """Get tool detail with permission validation. + + Args: + tool_name: Name of the tool + + Returns: + Tool detail dict containing: + - name: Tool name + - description: Tool description for LLM + - parameters: JSON schema of parameters + + Raises: + PermissionDeniedError: Tool not authorized for this run + """ + self._validate_tool_access(tool_name) + return await self._api.get_tool_detail(tool_name) async def call_tool( self, @@ -209,22 +214,15 @@ async def call_tool( Returns 'result' key instead of 'tool_response'. """ self._validate_tool_access(tool_name) + # LangBotAPIProxy.call_tool returns 'tool_response', we want 'result' + return await self._api.call_tool( + tool_name=tool_name, + parameters=parameters, + session=session or {}, + query_id=self.query_id, + ) - return ( - await self.plugin_runtime_handler.call_action( - PluginToRuntimeAction.CALL_TOOL, - { - "run_id": self.run_id, - "tool_name": tool_name, - "parameters": parameters, - "session": session or {}, - "query_id": self.query_id, - }, - timeout=180, - ) - )["result"] - - # ================= Knowledge Base API ================= + # ================= Knowledge Base API (delegated with validation) ================= async def retrieve_knowledge( self, @@ -233,128 +231,58 @@ async def retrieve_knowledge( top_k: int = 5, filters: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: - """Retrieve from knowledge base with permission validation. - - Uses RETRIEVE_KNOWLEDGE_BASE action (pipeline-scoped) with run_id. - """ + """Retrieve from knowledge base with permission validation.""" self._validate_knowledge_base_access(kb_id) + return await self._api.retrieve_knowledge( + kb_id=kb_id, + query_text=query_text, + top_k=top_k, + filters=filters, + ) - return ( - await self.plugin_runtime_handler.call_action( - PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE, - { - "run_id": self.run_id, - "query_id": self.query_id, - "kb_id": kb_id, - "query_text": query_text, - "top_k": top_k, - "filters": filters or {}, - }, - timeout=30, - ) - )["results"] - - # ================= Storage APIs ================= + # ================= Storage APIs (delegated with validation) ================= async def set_plugin_storage(self, key: str, value: bytes) -> None: """Set a plugin storage value with permission validation.""" self._validate_plugin_storage_access() - await self.plugin_runtime_handler.call_action( - PluginToRuntimeAction.SET_PLUGIN_STORAGE, - { - "key": key, - "value_base64": base64.b64encode(value).decode("utf-8"), - "run_id": self.run_id, # Host-side validation - }, - ) + await self._api.set_plugin_storage(key, value) async def get_plugin_storage(self, key: str) -> bytes: """Get a plugin storage value with permission validation.""" self._validate_plugin_storage_access() - resp = ( - await self.plugin_runtime_handler.call_action( - PluginToRuntimeAction.GET_PLUGIN_STORAGE, - { - "key": key, - "run_id": self.run_id, # Host-side validation - }, - ) - )["value_base64"] - return base64.b64decode(resp) + return await self._api.get_plugin_storage(key) async def get_plugin_storage_keys(self) -> list[str]: """Get all plugin storage keys with permission validation.""" self._validate_plugin_storage_access() - return ( - await self.plugin_runtime_handler.call_action( - PluginToRuntimeAction.GET_PLUGIN_STORAGE_KEYS, - { - "run_id": self.run_id, # Host-side validation - }, - ) - )["keys"] + return await self._api.get_plugin_storage_keys() async def delete_plugin_storage(self, key: str) -> None: """Delete a plugin storage value with permission validation.""" self._validate_plugin_storage_access() - await self.plugin_runtime_handler.call_action( - PluginToRuntimeAction.DELETE_PLUGIN_STORAGE, - { - "key": key, - "run_id": self.run_id, # Host-side validation - }, - ) + await self._api.delete_plugin_storage(key) async def set_workspace_storage(self, key: str, value: bytes) -> None: """Set a workspace storage value with permission validation.""" self._validate_workspace_storage_access() - await self.plugin_runtime_handler.call_action( - PluginToRuntimeAction.SET_WORKSPACE_STORAGE, - { - "key": key, - "value_base64": base64.b64encode(value).decode("utf-8"), - "run_id": self.run_id, # Host-side validation - }, - ) + await self._api.set_workspace_storage(key, value) async def get_workspace_storage(self, key: str) -> bytes: """Get a workspace storage value with permission validation.""" self._validate_workspace_storage_access() - resp = ( - await self.plugin_runtime_handler.call_action( - PluginToRuntimeAction.GET_WORKSPACE_STORAGE, - { - "key": key, - "run_id": self.run_id, # Host-side validation - }, - ) - )["value_base64"] - return base64.b64decode(resp) + return await self._api.get_workspace_storage(key) async def get_workspace_storage_keys(self) -> list[str]: """Get all workspace storage keys with permission validation.""" self._validate_workspace_storage_access() - return ( - await self.plugin_runtime_handler.call_action( - PluginToRuntimeAction.GET_WORKSPACE_STORAGE_KEYS, - { - "run_id": self.run_id, # Host-side validation - }, - ) - )["keys"] + return await self._api.get_workspace_storage_keys() async def delete_workspace_storage(self, key: str) -> None: """Delete a workspace storage value with permission validation.""" self._validate_workspace_storage_access() - await self.plugin_runtime_handler.call_action( - PluginToRuntimeAction.DELETE_WORKSPACE_STORAGE, - { - "key": key, - "run_id": self.run_id, # Host-side validation - }, - ) + await self._api.delete_workspace_storage(key) - # ================= File API ================= + # ================= File API (delegated with validation) ================= async def get_file(self, file_key: str) -> bytes: """Get a file with permission validation. @@ -366,28 +294,15 @@ async def get_file(self, file_key: str) -> bytes: The file content as bytes """ self._validate_file_access(file_key) - resp = ( - await self.plugin_runtime_handler.call_action( - PluginToRuntimeAction.GET_CONFIG_FILE, - { - "file_key": file_key, - "run_id": self.run_id, # Host-side validation - }, - ) - )["file_base64"] - return base64.b64decode(resp) + return await self._api.get_config_file(file_key) - # ================= Version API (no authorization needed) ================= + # ================= Version API (no authorization needed, delegated) ================= async def get_langbot_version(self) -> str: """Get the LangBot version (no authorization needed).""" - return ( - await self.plugin_runtime_handler.call_action( - PluginToRuntimeAction.GET_LANGBOT_VERSION, {} - ) - )["version"] + return await self._api.get_langbot_version() - # ================= Rerank API ================= + # ================= Rerank API (AgentRunner-specific, not in LangBotAPIProxy) ================= async def invoke_rerank( self, @@ -425,7 +340,7 @@ async def invoke_rerank( """ # Note: Rerank model access is validated by LangBot host # The model must be in ctx.resources.models with rerank capability - resp = await self.plugin_runtime_handler.call_action( + resp = await self._api.plugin_runtime_handler.call_action( PluginToRuntimeAction.INVOKE_RERANK, { "run_id": self.run_id, @@ -436,4 +351,4 @@ async def invoke_rerank( }, timeout=timeout, ) - return resp.get("results", []) \ No newline at end of file + return resp.get("results", []) From 5c3ae68a61076a3248cce4b4d1149eee49d365f1 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Sat, 16 May 2026 09:35:53 +0800 Subject: [PATCH 08/16] feat: route agent run APIs through runtime actions --- .../api/proxies/agent_run_api.py | 170 ++++++++++++++---- .../runtime/io/handlers/plugin.py | 23 ++- tests/api/proxies/test_agent_run_api_proxy.py | 22 ++- 3 files changed, 160 insertions(+), 55 deletions(-) diff --git a/src/langbot_plugin/api/proxies/agent_run_api.py b/src/langbot_plugin/api/proxies/agent_run_api.py index 4e7d9254..253bac0e 100644 --- a/src/langbot_plugin/api/proxies/agent_run_api.py +++ b/src/langbot_plugin/api/proxies/agent_run_api.py @@ -11,6 +11,7 @@ from __future__ import annotations +import base64 from typing import Any from langbot_plugin.runtime.io.handler import Handler @@ -157,13 +158,20 @@ async def invoke_llm( ) -> provider_message.Message: """Invoke an LLM model with permission validation.""" self._validate_model_access(llm_model_uuid) - return await self._api.invoke_llm( - llm_model_uuid=llm_model_uuid, - messages=messages, - funcs=funcs, - extra_args=extra_args, - timeout=timeout, + effective_timeout = timeout if timeout is not None else 120.0 + resp = await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.INVOKE_LLM, + { + "run_id": self.run_id, + "llm_model_uuid": llm_model_uuid, + "messages": [m.model_dump() for m in messages], + "funcs": [f.model_dump() for f in funcs], + "extra_args": extra_args, + "timeout": effective_timeout, + }, + effective_timeout, ) + return provider_message.Message.model_validate(resp["message"]) async def invoke_llm_stream( self, @@ -174,13 +182,19 @@ async def invoke_llm_stream( ): """Invoke an LLM model with streaming, permission validation.""" self._validate_model_access(llm_model_uuid) - async for chunk in self._api.invoke_llm_stream( - llm_model_uuid=llm_model_uuid, - messages=messages, - funcs=funcs, - extra_args=extra_args, + async for chunk_data in self._api.plugin_runtime_handler.call_action_generator( + PluginToRuntimeAction.INVOKE_LLM_STREAM, + { + "run_id": self.run_id, + "llm_model_uuid": llm_model_uuid, + "messages": [m.model_dump() for m in messages], + "funcs": [f.model_dump() for f in funcs], + "extra_args": extra_args, + "timeout": 120.0, + }, + 120.0, ): - yield chunk + yield provider_message.MessageChunk.model_validate(chunk_data["chunk"]) # ================= Tool APIs (delegated with validation) ================= @@ -200,7 +214,15 @@ async def get_tool_detail(self, tool_name: str) -> dict[str, Any]: PermissionDeniedError: Tool not authorized for this run """ self._validate_tool_access(tool_name) - return await self._api.get_tool_detail(tool_name) + resp = await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.GET_TOOL_DETAIL, + { + "run_id": self.run_id, + "tool_name": tool_name, + }, + 30, + ) + return resp.get("tool", resp) async def call_tool( self, @@ -214,13 +236,18 @@ async def call_tool( Returns 'result' key instead of 'tool_response'. """ self._validate_tool_access(tool_name) - # LangBotAPIProxy.call_tool returns 'tool_response', we want 'result' - return await self._api.call_tool( - tool_name=tool_name, - parameters=parameters, - session=session or {}, - query_id=self.query_id, + resp = await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.CALL_TOOL, + { + "run_id": self.run_id, + "tool_name": tool_name, + "parameters": parameters, + "session": session or {}, + "query_id": self.query_id, + }, + 180, ) + return resp.get("result", resp.get("tool_response", resp)) # ================= Knowledge Base API (delegated with validation) ================= @@ -233,54 +260,120 @@ async def retrieve_knowledge( ) -> list[dict[str, Any]]: """Retrieve from knowledge base with permission validation.""" self._validate_knowledge_base_access(kb_id) - return await self._api.retrieve_knowledge( - kb_id=kb_id, - query_text=query_text, - top_k=top_k, - filters=filters, - ) + return ( + await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE, + { + "run_id": self.run_id, + "query_id": self.query_id, + "kb_id": kb_id, + "query_text": query_text, + "top_k": top_k, + "filters": filters or {}, + }, + 30, + ) + )["results"] # ================= Storage APIs (delegated with validation) ================= async def set_plugin_storage(self, key: str, value: bytes) -> None: """Set a plugin storage value with permission validation.""" self._validate_plugin_storage_access() - await self._api.set_plugin_storage(key, value) + await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.SET_PLUGIN_STORAGE, + { + "run_id": self.run_id, + "key": key, + "value_base64": base64.b64encode(value).decode("utf-8"), + }, + ) async def get_plugin_storage(self, key: str) -> bytes: """Get a plugin storage value with permission validation.""" self._validate_plugin_storage_access() - return await self._api.get_plugin_storage(key) + resp = ( + await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.GET_PLUGIN_STORAGE, + { + "run_id": self.run_id, + "key": key, + }, + ) + )["value_base64"] + return base64.b64decode(resp) async def get_plugin_storage_keys(self) -> list[str]: """Get all plugin storage keys with permission validation.""" self._validate_plugin_storage_access() - return await self._api.get_plugin_storage_keys() + return ( + await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.GET_PLUGIN_STORAGE_KEYS, + { + "run_id": self.run_id, + }, + ) + )["keys"] async def delete_plugin_storage(self, key: str) -> None: """Delete a plugin storage value with permission validation.""" self._validate_plugin_storage_access() - await self._api.delete_plugin_storage(key) + await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.DELETE_PLUGIN_STORAGE, + { + "run_id": self.run_id, + "key": key, + }, + ) async def set_workspace_storage(self, key: str, value: bytes) -> None: """Set a workspace storage value with permission validation.""" self._validate_workspace_storage_access() - await self._api.set_workspace_storage(key, value) + await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.SET_WORKSPACE_STORAGE, + { + "run_id": self.run_id, + "key": key, + "value_base64": base64.b64encode(value).decode("utf-8"), + }, + ) async def get_workspace_storage(self, key: str) -> bytes: """Get a workspace storage value with permission validation.""" self._validate_workspace_storage_access() - return await self._api.get_workspace_storage(key) + resp = ( + await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.GET_WORKSPACE_STORAGE, + { + "run_id": self.run_id, + "key": key, + }, + ) + )["value_base64"] + return base64.b64decode(resp) async def get_workspace_storage_keys(self) -> list[str]: """Get all workspace storage keys with permission validation.""" self._validate_workspace_storage_access() - return await self._api.get_workspace_storage_keys() + return ( + await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.GET_WORKSPACE_STORAGE_KEYS, + { + "run_id": self.run_id, + }, + ) + )["keys"] async def delete_workspace_storage(self, key: str) -> None: """Delete a workspace storage value with permission validation.""" self._validate_workspace_storage_access() - await self._api.delete_workspace_storage(key) + await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.DELETE_WORKSPACE_STORAGE, + { + "run_id": self.run_id, + "key": key, + }, + ) # ================= File API (delegated with validation) ================= @@ -294,7 +387,16 @@ async def get_file(self, file_key: str) -> bytes: The file content as bytes """ self._validate_file_access(file_key) - return await self._api.get_config_file(file_key) + resp = ( + await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.GET_CONFIG_FILE, + { + "run_id": self.run_id, + "file_key": file_key, + }, + ) + )["file_base64"] + return base64.b64decode(resp) # ================= Version API (no authorization needed, delegated) ================= diff --git a/src/langbot_plugin/runtime/io/handlers/plugin.py b/src/langbot_plugin/runtime/io/handlers/plugin.py index 06fd905f..441acd92 100644 --- a/src/langbot_plugin/runtime/io/handlers/plugin.py +++ b/src/langbot_plugin/runtime/io/handlers/plugin.py @@ -621,16 +621,21 @@ async def list_tools(data: dict[str, Any]) -> handler.ActionResponse: @self.action(PluginToRuntimeAction.GET_TOOL_DETAIL) async def get_tool_detail(data: dict[str, Any]) -> handler.ActionResponse: - tool_name = data["tool_name"] - tools = await self.context.plugin_mgr.list_tools() - for tool in tools: - if tool.metadata.name == tool_name: - return handler.ActionResponse.success( - {"tool": tool.to_plain_dict()} - ) - return handler.ActionResponse.error( - message=f"Tool not found: {tool_name}" + """Forward tool detail requests to LangBot Host. + + AgentRunner calls include run_id so Host can validate the tool against + the active run session. Legacy plugin calls still work without run_id. + """ + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + + result = await self.context.control_handler.call_action( + PluginToRuntimeAction.GET_TOOL_DETAIL, + data, + timeout=30, ) + return handler.ActionResponse.success(result) @self.action(PluginToRuntimeAction.CALL_TOOL) async def call_tool_from_plugin(data: dict[str, Any]) -> handler.ActionResponse: diff --git a/tests/api/proxies/test_agent_run_api_proxy.py b/tests/api/proxies/test_agent_run_api_proxy.py index 9bac188b..1b75a992 100644 --- a/tests/api/proxies/test_agent_run_api_proxy.py +++ b/tests/api/proxies/test_agent_run_api_proxy.py @@ -7,15 +7,13 @@ """ from __future__ import annotations +from unittest.mock import AsyncMock, MagicMock + import pytest -from unittest.mock import AsyncMock, MagicMock, patch -import typing from langbot_plugin.api.proxies.agent_run_api import AgentRunAPIProxy, PermissionDeniedError from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction -from langbot_plugin.runtime.io.handler import Handler from langbot_plugin.api.entities.builtin.provider.message import Message -from langbot_plugin.api.entities.builtin.resource.tool import LLMTool from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext from langbot_plugin.api.entities.builtin.agent_runner.resources import ( AgentResources, @@ -98,13 +96,13 @@ def test_does_not_expose_list_tools(self): assert not hasattr(proxy, 'list_tools'), \ "AgentRunAPIProxy should not expose list_tools (use get_allowed_tools() instead)" - def test_does_not_expose_get_tool_detail(self): - """AgentRunAPIProxy should NOT have get_tool_detail method.""" + def test_exposes_get_tool_detail_with_validation(self): + """AgentRunAPIProxy exposes get_tool_detail() for authorized tool schemas.""" ctx = create_mock_context() proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) - assert not hasattr(proxy, 'get_tool_detail'), \ - "AgentRunAPIProxy should not expose get_tool_detail (use get_allowed_tools() instead)" + assert hasattr(proxy, 'get_tool_detail'), \ + "AgentRunAPIProxy should expose get_tool_detail() for authorized function calling" def test_does_not_expose_vector_upsert(self): """AgentRunAPIProxy should NOT have vector_upsert method.""" @@ -210,7 +208,7 @@ async def test_invoke_llm_with_authorized_model(self): proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) messages = [Message(role='user', content='Hello')] - result = await proxy.invoke_llm('model_001', messages) + await proxy.invoke_llm('model_001', messages) call_args = mock_handler.call_action_mock.call_args data = call_args[0][1] @@ -250,7 +248,7 @@ async def test_call_tool_with_authorized_tool(self): ) proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) - result = await proxy.call_tool('web_search', {'query': 'hello'}) + await proxy.call_tool('web_search', {'query': 'hello'}) call_args = mock_handler.call_action_mock.call_args data = call_args[0][1] @@ -290,7 +288,7 @@ async def test_retrieve_knowledge_with_authorized_kb(self): proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) # Note: query_id is NOT passed - auto-filled from ctx - results = await proxy.retrieve_knowledge('kb_001', 'search query') + await proxy.retrieve_knowledge('kb_001', 'search query') call_args = mock_handler.call_action_mock.call_args data = call_args[0][1] @@ -664,4 +662,4 @@ async def test_retrieve_knowledge_sends_correct_fields(self): assert 'kb_id' in data assert 'query_text' in data assert 'top_k' in data - assert 'filters' in data \ No newline at end of file + assert 'filters' in data From 95f45d6bf9aea7a67c760b4bc93d23d330d234fb Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Sat, 16 May 2026 10:34:16 +0800 Subject: [PATCH 09/16] fix(rag): restore knowledge engine runtime bridge --- src/langbot_plugin/api/proxies/langbot_api.py | 2 +- .../entities/io/actions/enums.py | 5 ++++- .../runtime/io/handlers/control.py | 13 ++++++++++++ .../runtime/io/handlers/plugin.py | 4 ++-- src/langbot_plugin/runtime/plugin/mgr.py | 21 +++++++++++++++++++ 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/langbot_plugin/api/proxies/langbot_api.py b/src/langbot_plugin/api/proxies/langbot_api.py index 489b4b57..43f11261 100644 --- a/src/langbot_plugin/api/proxies/langbot_api.py +++ b/src/langbot_plugin/api/proxies/langbot_api.py @@ -457,7 +457,7 @@ async def get_knowledge_file_stream(self, storage_path: str) -> bytes: File content bytes. """ resp = await self.plugin_runtime_handler.call_action( - PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM, + PluginToRuntimeAction.GET_KNOWLEDGE_FILE_STREAM, {"storage_path": storage_path}, ) # File was transferred via FILE_CHUNK; read from local temp diff --git a/src/langbot_plugin/entities/io/actions/enums.py b/src/langbot_plugin/entities/io/actions/enums.py index a92d4696..fd9806f3 100644 --- a/src/langbot_plugin/entities/io/actions/enums.py +++ b/src/langbot_plugin/entities/io/actions/enums.py @@ -66,7 +66,7 @@ class PluginToRuntimeAction(ActionType): VECTOR_SEARCH = "vector_search" VECTOR_DELETE = "vector_delete" VECTOR_LIST = "vector_list" - GET_KNOWLEDEGE_FILE_STREAM = "get_knowledge_file_stream" + GET_KNOWLEDGE_FILE_STREAM = "get_knowledge_file_stream" LIST_PARSERS = "list_parsers" INVOKE_PARSER = "invoke_parser" @@ -126,6 +126,9 @@ class LangBotToRuntimeAction(ActionType): LIST_COMMANDS = "list_commands" EXECUTE_COMMAND = "execute_command" + # KnowledgeEngine retrieval action + RETRIEVE_KNOWLEDGE = "retrieve_knowledge" + # AgentRunner actions LIST_AGENT_RUNNERS = "list_agent_runners" RUN_AGENT = "run_agent" diff --git a/src/langbot_plugin/runtime/io/handlers/control.py b/src/langbot_plugin/runtime/io/handlers/control.py index a35730b7..25c262c9 100644 --- a/src/langbot_plugin/runtime/io/handlers/control.py +++ b/src/langbot_plugin/runtime/io/handlers/control.py @@ -278,6 +278,19 @@ async def run_agent( ): yield handler.ActionResponse.success(result) + @self.action(LangBotToRuntimeAction.RETRIEVE_KNOWLEDGE) + async def retrieve_knowledge(data: dict[str, Any]) -> handler.ActionResponse: + """Retrieve knowledge using a KnowledgeEngine instance.""" + plugin_author = data["plugin_author"] + plugin_name = data["plugin_name"] + retriever_name = data["retriever_name"] + retrieval_context = data["retrieval_context"] + + resp = await self.context.plugin_mgr.retrieve_knowledge( + plugin_author, plugin_name, retriever_name, retrieval_context + ) + return handler.ActionResponse.success(resp) + @self.action(LangBotToRuntimeAction.GET_DEBUG_INFO) async def get_debug_info(data: dict[str, Any]) -> handler.ActionResponse: """Get debug information including debug key and WS URL.""" diff --git a/src/langbot_plugin/runtime/io/handlers/plugin.py b/src/langbot_plugin/runtime/io/handlers/plugin.py index 441acd92..80373117 100644 --- a/src/langbot_plugin/runtime/io/handlers/plugin.py +++ b/src/langbot_plugin/runtime/io/handlers/plugin.py @@ -320,13 +320,13 @@ async def vector_delete(data: dict[str, Any]) -> handler.ActionResponse: ) return handler.ActionResponse.success(result) - @self.action(PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM) + @self.action(PluginToRuntimeAction.GET_KNOWLEDGE_FILE_STREAM) async def get_knowledge_file_stream( data: dict[str, Any], ) -> handler.ActionResponse: """Forward file stream from LangBot to plugin via chunked transfer.""" result = await _proxy_rag_action( - PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM, + PluginToRuntimeAction.GET_KNOWLEDGE_FILE_STREAM, data, timeout=60, ) diff --git a/src/langbot_plugin/runtime/plugin/mgr.py b/src/langbot_plugin/runtime/plugin/mgr.py index 6c274ff4..af7910ef 100644 --- a/src/langbot_plugin/runtime/plugin/mgr.py +++ b/src/langbot_plugin/runtime/plugin/mgr.py @@ -955,6 +955,27 @@ async def run_agent( code="runner.forward_exception", ).model_dump(mode="json") + async def retrieve_knowledge( + self, + plugin_author: str, + plugin_name: str, + retriever_name: str, + retrieval_context: dict[str, typing.Any], + ) -> dict[str, typing.Any]: + """Retrieve knowledge using a KnowledgeEngine instance.""" + target_plugin = self.find_plugin(plugin_author, plugin_name) + + if target_plugin is None: + raise ValueError(f"Plugin {plugin_author}/{plugin_name} not found") + + if target_plugin._runtime_plugin_handler is None: + raise ValueError(f"Plugin {plugin_author}/{plugin_name} is not connected") + + resp = await target_plugin._runtime_plugin_handler.retrieve_knowledge( + retriever_name, retrieval_context + ) + return resp + # ================= Knowledge Engine Methods ================= def _find_knowledge_engine_plugin( From aac49bcfefe0a0c1271617cde717a2ebed5e0105 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Sun, 17 May 2026 23:26:52 +0800 Subject: [PATCH 10/16] feat(agent-runner): expose effective prompt and guarded run APIs --- .../entities/builtin/agent_runner/context.py | 11 ++++- .../api/proxies/agent_run_api.py | 48 +++++++++++-------- .../agent_runner/test_context_result.py | 8 +++- tests/api/proxies/test_agent_run_api_proxy.py | 34 +++++++++++++ 4 files changed, 80 insertions(+), 21 deletions(-) diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/context.py b/src/langbot_plugin/api/entities/builtin/agent_runner/context.py index 3e4974e6..6ef39992 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/context.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/context.py @@ -29,6 +29,7 @@ class AgentRunContext(pydantic.BaseModel): - event: event envelope subset (for future EBA) - actor: who triggered the event - subject: what the event is about + - prompt: effective prompt/instruction messages prepared by the host - messages: historical conversation messages - input: user input - params: single-run public business parameters (read-only, non-persistent) @@ -65,6 +66,14 @@ class AgentRunContext(pydantic.BaseModel): messages: list[Message] = pydantic.Field(default_factory=list) """Historical messages in the conversation.""" + prompt: list[Message] = pydantic.Field(default_factory=list) + """Effective prompt/instruction messages prepared by the host. + + This is the prompt after host-side preprocessing and prompt-related plugin + events have run. Runners should prefer this over static prompt data in + config when they need to call a model directly. + """ + input: AgentInput """User input.""" @@ -109,4 +118,4 @@ class AgentRunContext(pydantic.BaseModel): """Runner instance configuration.""" class Config: - arbitrary_types_allowed = True \ No newline at end of file + arbitrary_types_allowed = True diff --git a/src/langbot_plugin/api/proxies/agent_run_api.py b/src/langbot_plugin/api/proxies/agent_run_api.py index 253bac0e..2b427fcf 100644 --- a/src/langbot_plugin/api/proxies/agent_run_api.py +++ b/src/langbot_plugin/api/proxies/agent_run_api.py @@ -51,7 +51,7 @@ class AgentRunAPIProxy: - get_allowed_files(): returns ctx.resources.files Additional APIs (AgentRunner-specific): - - invoke_rerank(): requires rerank model authorization (validated by host) + - invoke_rerank(): requires rerank model authorization in ctx.resources Not available (platform actions, use AgentRunResult.action_requested instead): - get_bots() / get_bot_info() / send_message() @@ -155,20 +155,24 @@ async def invoke_llm( funcs: list[resource_tool.LLMTool] = [], extra_args: dict[str, Any] = {}, timeout: float | None = None, + remove_think: bool | None = None, ) -> provider_message.Message: """Invoke an LLM model with permission validation.""" self._validate_model_access(llm_model_uuid) effective_timeout = timeout if timeout is not None else 120.0 + payload = { + "run_id": self.run_id, + "llm_model_uuid": llm_model_uuid, + "messages": [m.model_dump() for m in messages], + "funcs": [f.model_dump() for f in funcs], + "extra_args": extra_args, + "timeout": effective_timeout, + } + if remove_think is not None: + payload["remove_think"] = remove_think resp = await self._api.plugin_runtime_handler.call_action( PluginToRuntimeAction.INVOKE_LLM, - { - "run_id": self.run_id, - "llm_model_uuid": llm_model_uuid, - "messages": [m.model_dump() for m in messages], - "funcs": [f.model_dump() for f in funcs], - "extra_args": extra_args, - "timeout": effective_timeout, - }, + payload, effective_timeout, ) return provider_message.Message.model_validate(resp["message"]) @@ -179,19 +183,23 @@ async def invoke_llm_stream( messages: list[provider_message.Message], funcs: list[resource_tool.LLMTool] = [], extra_args: dict[str, Any] = {}, + remove_think: bool | None = None, ): """Invoke an LLM model with streaming, permission validation.""" self._validate_model_access(llm_model_uuid) + payload = { + "run_id": self.run_id, + "llm_model_uuid": llm_model_uuid, + "messages": [m.model_dump() for m in messages], + "funcs": [f.model_dump() for f in funcs], + "extra_args": extra_args, + "timeout": 120.0, + } + if remove_think is not None: + payload["remove_think"] = remove_think async for chunk_data in self._api.plugin_runtime_handler.call_action_generator( PluginToRuntimeAction.INVOKE_LLM_STREAM, - { - "run_id": self.run_id, - "llm_model_uuid": llm_model_uuid, - "messages": [m.model_dump() for m in messages], - "funcs": [f.model_dump() for f in funcs], - "extra_args": extra_args, - "timeout": 120.0, - }, + payload, 120.0, ): yield provider_message.MessageChunk.model_validate(chunk_data["chunk"]) @@ -412,6 +420,7 @@ async def invoke_rerank( query: str, documents: list[str], top_k: int | None = None, + extra_args: dict[str, Any] | None = None, timeout: float = 30.0, ) -> list[dict[str, Any]]: """Invoke a rerank model to re-score documents. @@ -421,6 +430,7 @@ async def invoke_rerank( query: The query text for reranking documents: List of document texts to rerank top_k: Optional number of top results to return + extra_args: Optional provider-specific options timeout: Request timeout in seconds Returns: @@ -440,8 +450,7 @@ async def invoke_rerank( # ... # ] """ - # Note: Rerank model access is validated by LangBot host - # The model must be in ctx.resources.models with rerank capability + self._validate_model_access(rerank_model_uuid) resp = await self._api.plugin_runtime_handler.call_action( PluginToRuntimeAction.INVOKE_RERANK, { @@ -450,6 +459,7 @@ async def invoke_rerank( "query": query, "documents": documents, "top_k": top_k, + "extra_args": extra_args or {}, }, timeout=timeout, ) diff --git a/tests/api/entities/builtin/agent_runner/test_context_result.py b/tests/api/entities/builtin/agent_runner/test_context_result.py index 4fda4131..f292ee9c 100644 --- a/tests/api/entities/builtin/agent_runner/test_context_result.py +++ b/tests/api/entities/builtin/agent_runner/test_context_result.py @@ -64,6 +64,7 @@ def test_minimal_context_validate(self): assert ctx.trigger.type == "message.received" assert ctx.input.text == "Hello" assert ctx.messages == [] + assert ctx.prompt == [] assert ctx.config == {} # New fields: params and state have defaults assert ctx.params == {} @@ -166,6 +167,9 @@ def test_full_context_validate(self): Message(role="user", content="Hi"), Message(role="assistant", content="Hello"), ] + prompt = [ + Message(role="system", content="You are helpful."), + ] input = AgentInput( text="What's up?", contents=[ContentElement(type="text", text="What's up?")], @@ -198,6 +202,7 @@ def test_full_context_validate(self): actor=actor, subject=subject, messages=messages, + prompt=prompt, input=input, params=params, resources=resources, @@ -209,6 +214,7 @@ def test_full_context_validate(self): assert ctx.run_id == "run_full" assert ctx.conversation.launcher_type == "person" assert ctx.resources.models[0].model_id == "gpt-4" + assert ctx.prompt[0].content == "You are helpful." assert len(ctx.messages) == 2 assert ctx.config["model"] == "gpt-4" assert ctx.params["workflow_input"] == "test_workflow" @@ -513,4 +519,4 @@ def test_permissions_from_dict(self): ) assert perms.models == ["list", "invoke", "stream"] assert perms.tools == ["list", "detail", "call"] - assert perms.storage == ["plugin", "workspace"] \ No newline at end of file + assert perms.storage == ["plugin", "workspace"] diff --git a/tests/api/proxies/test_agent_run_api_proxy.py b/tests/api/proxies/test_agent_run_api_proxy.py index 1b75a992..d0a66d92 100644 --- a/tests/api/proxies/test_agent_run_api_proxy.py +++ b/tests/api/proxies/test_agent_run_api_proxy.py @@ -624,6 +624,40 @@ async def test_invoke_llm_sends_correct_fields(self): assert 'extra_args' in data assert 'timeout' in data + @pytest.mark.anyio + async def test_invoke_llm_can_send_remove_think_override(self): + """INVOKE_LLM: remove_think override is passed when supplied.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'message': {'role': 'assistant', 'content': 'Hello'}} + + ctx = create_mock_context(models=[{'model_id': 'model_001'}]) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.invoke_llm('model_001', [Message(role='user', content='Hello')], remove_think=True) + + call_args = mock_handler.call_action_mock.call_args + data = call_args[0][1] + + assert data['remove_think'] is True + + @pytest.mark.anyio + async def test_invoke_rerank_validates_authorized_model(self): + """INVOKE_RERANK: SDK validates rerank model through ctx.resources.models.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'results': []} + + ctx = create_mock_context(models=[{'model_id': 'rerank_001'}]) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.invoke_rerank('rerank_001', 'query', ['doc'], extra_args={'top_n': 2}) + + call_args = mock_handler.call_action_mock.call_args + data = call_args[0][1] + assert data['extra_args'] == {'top_n': 2} + + with pytest.raises(PermissionDeniedError): + await proxy.invoke_rerank('rerank_999', 'query', ['doc']) + @pytest.mark.anyio async def test_call_tool_sends_correct_fields(self): """CALL_TOOL: SDK fields match Host handler expectations.""" From 79a32792974b47c1e3b92466f91a5c128ebb9850 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Tue, 19 May 2026 12:20:28 +0800 Subject: [PATCH 11/16] feat: enforce agent runner deadlines --- .../entities/builtin/agent_runner/runtime.py | 2 +- .../api/proxies/agent_run_api.py | 54 +++++++++++++-- src/langbot_plugin/cli/run/handler.py | 51 +++++++++++++- src/langbot_plugin/runtime/plugin/mgr.py | 57 +++++++++++++++- tests/api/proxies/test_agent_run_api_proxy.py | 42 +++++++++++- tests/runtime/plugin/test_mgr_agent_runner.py | 67 ++++++++++++++++++- 6 files changed, 260 insertions(+), 13 deletions(-) diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/runtime.py b/src/langbot_plugin/api/entities/builtin/agent_runner/runtime.py index 747f6787..4ae752dd 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/runtime.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/runtime.py @@ -24,7 +24,7 @@ class AgentRuntimeContext(pydantic.BaseModel): trace_id: str | None = None """Trace ID for observability.""" - deadline_at: int | None = None + deadline_at: float | None = None """Deadline timestamp (epoch seconds) for timeout.""" metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict) diff --git a/src/langbot_plugin/api/proxies/agent_run_api.py b/src/langbot_plugin/api/proxies/agent_run_api.py index 2b427fcf..c1eb7113 100644 --- a/src/langbot_plugin/api/proxies/agent_run_api.py +++ b/src/langbot_plugin/api/proxies/agent_run_api.py @@ -12,6 +12,7 @@ from __future__ import annotations import base64 +import time from typing import Any from langbot_plugin.runtime.io.handler import Handler @@ -90,6 +91,31 @@ def query_id(self) -> int: """Query ID from runtime context (for legacy compatibility).""" return self.ctx.runtime.query_id or 0 + def _remaining_deadline_seconds(self) -> float | None: + deadline_at = self.ctx.runtime.deadline_at + if deadline_at is None: + return None + try: + return float(deadline_at) - time.time() + except (TypeError, ValueError): + return None + + def _bounded_timeout( + self, + default: float, + requested: float | None = None, + ) -> float: + base_timeout = default if requested is None else requested + if not isinstance(base_timeout, (int, float)) or base_timeout <= 0: + base_timeout = default + + remaining = self._remaining_deadline_seconds() + if remaining is None: + return float(base_timeout) + if remaining <= 0: + return 0.001 + return max(min(float(base_timeout), remaining), 0.001) + # ================= Resource Helper Methods ================= def get_allowed_models(self) -> list[Any]: @@ -159,7 +185,7 @@ async def invoke_llm( ) -> provider_message.Message: """Invoke an LLM model with permission validation.""" self._validate_model_access(llm_model_uuid) - effective_timeout = timeout if timeout is not None else 120.0 + effective_timeout = self._bounded_timeout(default=120.0, requested=timeout) payload = { "run_id": self.run_id, "llm_model_uuid": llm_model_uuid, @@ -187,20 +213,21 @@ async def invoke_llm_stream( ): """Invoke an LLM model with streaming, permission validation.""" self._validate_model_access(llm_model_uuid) + effective_timeout = self._bounded_timeout(default=120.0) payload = { "run_id": self.run_id, "llm_model_uuid": llm_model_uuid, "messages": [m.model_dump() for m in messages], "funcs": [f.model_dump() for f in funcs], "extra_args": extra_args, - "timeout": 120.0, + "timeout": effective_timeout, } if remove_think is not None: payload["remove_think"] = remove_think async for chunk_data in self._api.plugin_runtime_handler.call_action_generator( PluginToRuntimeAction.INVOKE_LLM_STREAM, payload, - 120.0, + effective_timeout, ): yield provider_message.MessageChunk.model_validate(chunk_data["chunk"]) @@ -222,13 +249,14 @@ async def get_tool_detail(self, tool_name: str) -> dict[str, Any]: PermissionDeniedError: Tool not authorized for this run """ self._validate_tool_access(tool_name) + timeout = self._bounded_timeout(default=30.0) resp = await self._api.plugin_runtime_handler.call_action( PluginToRuntimeAction.GET_TOOL_DETAIL, { "run_id": self.run_id, "tool_name": tool_name, }, - 30, + timeout, ) return resp.get("tool", resp) @@ -244,6 +272,7 @@ async def call_tool( Returns 'result' key instead of 'tool_response'. """ self._validate_tool_access(tool_name) + timeout = self._bounded_timeout(default=180.0) resp = await self._api.plugin_runtime_handler.call_action( PluginToRuntimeAction.CALL_TOOL, { @@ -253,7 +282,7 @@ async def call_tool( "session": session or {}, "query_id": self.query_id, }, - 180, + timeout, ) return resp.get("result", resp.get("tool_response", resp)) @@ -268,6 +297,7 @@ async def retrieve_knowledge( ) -> list[dict[str, Any]]: """Retrieve from knowledge base with permission validation.""" self._validate_knowledge_base_access(kb_id) + timeout = self._bounded_timeout(default=30.0) return ( await self._api.plugin_runtime_handler.call_action( PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE, @@ -279,7 +309,7 @@ async def retrieve_knowledge( "top_k": top_k, "filters": filters or {}, }, - 30, + timeout, ) )["results"] @@ -295,6 +325,7 @@ async def set_plugin_storage(self, key: str, value: bytes) -> None: "key": key, "value_base64": base64.b64encode(value).decode("utf-8"), }, + self._bounded_timeout(default=15.0), ) async def get_plugin_storage(self, key: str) -> bytes: @@ -307,6 +338,7 @@ async def get_plugin_storage(self, key: str) -> bytes: "run_id": self.run_id, "key": key, }, + self._bounded_timeout(default=15.0), ) )["value_base64"] return base64.b64decode(resp) @@ -320,6 +352,7 @@ async def get_plugin_storage_keys(self) -> list[str]: { "run_id": self.run_id, }, + self._bounded_timeout(default=15.0), ) )["keys"] @@ -332,6 +365,7 @@ async def delete_plugin_storage(self, key: str) -> None: "run_id": self.run_id, "key": key, }, + self._bounded_timeout(default=15.0), ) async def set_workspace_storage(self, key: str, value: bytes) -> None: @@ -344,6 +378,7 @@ async def set_workspace_storage(self, key: str, value: bytes) -> None: "key": key, "value_base64": base64.b64encode(value).decode("utf-8"), }, + self._bounded_timeout(default=15.0), ) async def get_workspace_storage(self, key: str) -> bytes: @@ -356,6 +391,7 @@ async def get_workspace_storage(self, key: str) -> bytes: "run_id": self.run_id, "key": key, }, + self._bounded_timeout(default=15.0), ) )["value_base64"] return base64.b64decode(resp) @@ -369,6 +405,7 @@ async def get_workspace_storage_keys(self) -> list[str]: { "run_id": self.run_id, }, + self._bounded_timeout(default=15.0), ) )["keys"] @@ -381,6 +418,7 @@ async def delete_workspace_storage(self, key: str) -> None: "run_id": self.run_id, "key": key, }, + self._bounded_timeout(default=15.0), ) # ================= File API (delegated with validation) ================= @@ -402,6 +440,7 @@ async def get_file(self, file_key: str) -> bytes: "run_id": self.run_id, "file_key": file_key, }, + self._bounded_timeout(default=15.0), ) )["file_base64"] return base64.b64decode(resp) @@ -451,6 +490,7 @@ async def invoke_rerank( # ] """ self._validate_model_access(rerank_model_uuid) + effective_timeout = self._bounded_timeout(default=30.0, requested=timeout) resp = await self._api.plugin_runtime_handler.call_action( PluginToRuntimeAction.INVOKE_RERANK, { @@ -461,6 +501,6 @@ async def invoke_rerank( "top_k": top_k, "extra_args": extra_args or {}, }, - timeout=timeout, + timeout=effective_timeout, ) return resp.get("results", []) diff --git a/src/langbot_plugin/cli/run/handler.py b/src/langbot_plugin/cli/run/handler.py index ac5ea1e2..c81f58eb 100644 --- a/src/langbot_plugin/cli/run/handler.py +++ b/src/langbot_plugin/cli/run/handler.py @@ -5,6 +5,7 @@ import mimetypes import typing import aiofiles +import time from copy import deepcopy from pathlib import Path @@ -63,6 +64,51 @@ def _resolve_asset_path(file_key: str) -> Path | None: return None +def _remaining_deadline_seconds(deadline_at: typing.Any) -> float | None: + if deadline_at is None: + return None + try: + return float(deadline_at) - time.time() + except (TypeError, ValueError): + return None + + +async def _iter_runner_results_with_deadline( + runner_instance: typing.Any, + run_context: typing.Any, +) -> typing.AsyncGenerator[typing.Any, None]: + """Iterate runner results and cancel the runner when the run deadline expires.""" + from langbot_plugin.api.entities.builtin.agent_runner.result import AgentRunResult + + result_gen = runner_instance.run(run_context) + try: + while True: + remaining = _remaining_deadline_seconds(run_context.runtime.deadline_at) + if remaining is not None and remaining <= 0: + raise asyncio.TimeoutError + + try: + if remaining is None: + result = await anext(result_gen) + else: + result = await asyncio.wait_for(anext(result_gen), timeout=remaining) + except StopAsyncIteration: + break + + yield result + except asyncio.TimeoutError: + yield AgentRunResult.run_failed( + error="Agent runner timed out", + code="runner.timeout", + retryable=True, + ) + finally: + try: + await result_gen.aclose() + except Exception: + pass + + class PluginRuntimeHandler(Handler): """The handler for running plugins.""" @@ -360,7 +406,10 @@ async def run_agent( # Run the agent and stream results try: - async for result in runner_instance.run(run_context): + async for result in _iter_runner_results_with_deadline( + runner_instance, + run_context, + ): yield ActionResponse.success(result.model_dump(mode="json")) except Exception as e: import traceback diff --git a/src/langbot_plugin/runtime/plugin/mgr.py b/src/langbot_plugin/runtime/plugin/mgr.py index af7910ef..baa524a4 100644 --- a/src/langbot_plugin/runtime/plugin/mgr.py +++ b/src/langbot_plugin/runtime/plugin/mgr.py @@ -33,6 +33,7 @@ RuntimeToLangBotAction, RuntimeToPluginAction, ) +from langbot_plugin.entities.io.errors import ActionCallTimeoutError from langbot_plugin.api.entities.builtin.command.context import ( ExecuteContext, CommandReturn, @@ -43,6 +44,48 @@ logger = logging.getLogger(__name__) +def _remaining_deadline_seconds(context: dict[str, typing.Any]) -> float | None: + deadline_at = (context.get("runtime") or {}).get("deadline_at") + if deadline_at is None: + return None + try: + return float(deadline_at) - time.time() + except (TypeError, ValueError): + return None + + +def _runner_action_timeout(context: dict[str, typing.Any]) -> float: + remaining = _remaining_deadline_seconds(context) + if remaining is None: + return 300 + if remaining <= 0: + return 0.001 + return max(remaining + 1.0, 0.001) + + +async def _anext_with_deadline( + gen: AsyncGenerator[dict[str, typing.Any], None], + context: dict[str, typing.Any], +) -> dict[str, typing.Any]: + remaining = _remaining_deadline_seconds(context) + if remaining is not None and remaining <= 0: + await gen.aclose() + raise asyncio.TimeoutError + + try: + if remaining is None: + return await anext(gen) + return await asyncio.wait_for(anext(gen), timeout=remaining) + except StopAsyncIteration: + exhausted = _remaining_deadline_seconds(context) + if exhausted is not None and exhausted <= 0: + raise asyncio.TimeoutError + raise + except asyncio.TimeoutError: + await gen.aclose() + raise + + class PluginInstallSource(enum.Enum): """The source of plugin installation.""" @@ -939,14 +982,24 @@ async def run_agent( "runner_name": runner_name, "context": context, }, - timeout=300, + timeout=_runner_action_timeout(context), ) # call_action_generator yields response.data directly on success, # or raises ActionCallError on failure - async for result_data in gen: + while True: + try: + result_data = await _anext_with_deadline(gen, context) + except StopAsyncIteration: + break yield result_data + except (asyncio.TimeoutError, ActionCallTimeoutError): + yield AgentRunResult.run_failed( + error="Agent runner timed out", + code="runner.timeout", + retryable=True, + ).model_dump(mode="json") except Exception as e: import traceback traceback.print_exc() diff --git a/tests/api/proxies/test_agent_run_api_proxy.py b/tests/api/proxies/test_agent_run_api_proxy.py index d0a66d92..6fb85dec 100644 --- a/tests/api/proxies/test_agent_run_api_proxy.py +++ b/tests/api/proxies/test_agent_run_api_proxy.py @@ -7,6 +7,7 @@ """ from __future__ import annotations +import time from unittest.mock import AsyncMock, MagicMock import pytest @@ -47,6 +48,7 @@ def call_action_generator(self, action: PluginToRuntimeAction, data: dict, timeo def create_mock_context( run_id: str = 'test_run', query_id: int | None = None, + deadline_at: float | None = None, models: list[dict] | None = None, tools: list[dict] | None = None, knowledge_bases: list[dict] | None = None, @@ -58,7 +60,7 @@ def create_mock_context( run_id=run_id, trigger=AgentTrigger(type='user_message'), input=AgentInput(content='test input'), - runtime=AgentRuntimeContext(query_id=query_id), + runtime=AgentRuntimeContext(query_id=query_id, deadline_at=deadline_at), resources=AgentResources( models=[ModelResource.model_validate(m) for m in (models or [])], tools=[ToolResource.model_validate(t) for t in (tools or [])], @@ -569,6 +571,44 @@ async def test_retrieve_knowledge_timeout_is_30(self): assert timeout == 30 + @pytest.mark.anyio + async def test_invoke_llm_timeout_is_bounded_by_run_deadline(self): + """invoke_llm timeout is capped by ctx.runtime.deadline_at.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'message': {'role': 'assistant', 'content': 'Hello'}} + + ctx = create_mock_context( + deadline_at=time.time() + 5, + models=[{'model_id': 'model_001'}], + ) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.invoke_llm('model_001', [Message(role='user', content='Hello')]) + + timeout = mock_handler.call_action_mock.call_args[0][2] + payload_timeout = mock_handler.call_action_mock.call_args[0][1]["timeout"] + + assert 0 < timeout <= 5 + assert payload_timeout == timeout + + @pytest.mark.anyio + async def test_call_tool_timeout_is_bounded_by_run_deadline(self): + """tool calls use the remaining run deadline instead of the full default.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'result': {}} + + ctx = create_mock_context( + deadline_at=time.time() + 5, + tools=[{'tool_name': 'test_tool'}], + ) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.call_tool('test_tool', {'param': 'value'}) + + timeout = mock_handler.call_action_mock.call_args[0][2] + + assert 0 < timeout <= 5 + class TestAgentRunAPIProxyActionEnumCorrectness: """Tests to ensure correct action enum usage.""" diff --git a/tests/runtime/plugin/test_mgr_agent_runner.py b/tests/runtime/plugin/test_mgr_agent_runner.py index db145041..55c43170 100644 --- a/tests/runtime/plugin/test_mgr_agent_runner.py +++ b/tests/runtime/plugin/test_mgr_agent_runner.py @@ -2,10 +2,12 @@ from __future__ import annotations -import pytest +import time import typing from unittest.mock import Mock +import pytest + from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext from langbot_plugin.api.entities.builtin.agent_runner.result import AgentRunResult from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger @@ -55,6 +57,18 @@ async def run( raise RuntimeError("Intentional test failure") +class SlowAgentRunner(AgentRunner): + """Mock AgentRunner that exceeds the run deadline.""" + + async def run( + self, ctx: AgentRunContext + ) -> typing.AsyncGenerator[AgentRunResult, None]: + import asyncio + + await asyncio.sleep(0.05) + yield AgentRunResult.run_completed(finish_reason="stop") + + def create_mock_component_manifest( runner_name: str, spec: dict | None = None, @@ -476,3 +490,54 @@ async def test_run_agent_runner_not_initialized_forwarded(self): assert len(results) == 1 assert results[0]["type"] == "run.failed" assert results[0]["data"]["code"] == "runner.not_initialized" + + @pytest.mark.anyio + async def test_run_agent_deadline_returns_timeout(self): + """PluginManager enforces total runner deadline while forwarding.""" + from langbot_plugin.runtime.plugin.mgr import PluginManager + from langbot_plugin.runtime.context import RuntimeContext + + mock_context = Mock(spec=RuntimeContext) + mgr = PluginManager(mock_context) + + mock_responses = [ + {"type": "run.completed", "data": {"finish_reason": "stop"}}, + ] + plugin = create_mock_plugin( + "test-author", "test-plugin", [("default", None)], + mock_handler_responses=[mock_responses], + ) + mgr.plugins = [plugin] + + ctx = create_run_context() + ctx.runtime.deadline_at = time.time() - 1 + + results = [] + async for result in mgr.run_agent( + "test-author", + "test-plugin", + "default", + ctx.model_dump(mode="json"), + ): + results.append(result) + + assert len(results) == 1 + assert results[0]["type"] == "run.failed" + assert results[0]["data"]["code"] == "runner.timeout" + assert results[0]["data"]["retryable"] is True + + +@pytest.mark.anyio +async def test_plugin_runtime_runner_deadline_cancels_runner_coroutine(): + """The plugin-process helper converts an expired runner coroutine to run.failed.""" + from langbot_plugin.cli.run.handler import _iter_runner_results_with_deadline + + ctx = create_run_context() + ctx.runtime.deadline_at = time.time() + 0.01 + + results = [result async for result in _iter_runner_results_with_deadline(SlowAgentRunner(), ctx)] + + assert len(results) == 1 + assert results[0].type.value == "run.failed" + assert results[0].data["code"] == "runner.timeout" + assert results[0].data["retryable"] is True From 5877b7a2a4424037cddb6c5daa478c2798f17e58 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Sat, 23 May 2026 16:08:07 +0800 Subject: [PATCH 12/16] feat(agent-runner): define protocol v1 context and pull APIs Define Protocol v1 AgentRunContext with required fields: event, delivery, context (ContextAccess), bootstrap, compatibility, metadata. Add history and event pull APIs to AgentRunAPIProxy with run_id authorization. Key changes: - AgentRunContext: Protocol v1 structure with required event/delivery fields - AgentEventContext: event_id, event_type, source required - DeliveryContext: surface, streaming/edit/reaction capabilities - ContextAccess: inline_policy, available_apis for pull APIs - BootstrapContext: optional bootstrap messages (NOT core history) - CompatibilityContext: legacy Query/Pipeline fields for migration - AgentRunResult factories: all require run_id parameter - AgentRunAPIProxy: history_page, history_search, event_get, event_page - PluginToRuntimeAction: HISTORY_PAGE, HISTORY_SEARCH, EVENT_GET, EVENT_PAGE - Legacy helpers: to_v1_result(run_id), create_legacy_context with event/delivery Runner docs updated: use ctx.run_id in factories, ctx.bootstrap.messages instead of ctx.messages (demoted to bootstrap for Protocol v1). --- .../components/agent_runner/runner.py | 45 ++- .../entities/builtin/agent_runner/__init__.py | 56 ++- .../builtin/agent_runner/bootstrap.py | 36 ++ .../builtin/agent_runner/capabilities.py | 7 +- .../entities/builtin/agent_runner/context.py | 99 ++--- .../builtin/agent_runner/context_access.py | 96 +++++ .../builtin/agent_runner/context_policy.py | 55 +++ .../entities/builtin/agent_runner/delivery.py | 38 ++ .../entities/builtin/agent_runner/event.py | 71 +++- .../entities/builtin/agent_runner/input.py | 29 +- .../entities/builtin/agent_runner/legacy.py | 64 ++- .../entities/builtin/agent_runner/manifest.py | 96 +++++ .../builtin/agent_runner/page_results.py | 146 +++++++ .../builtin/agent_runner/permissions.py | 21 +- .../entities/builtin/agent_runner/result.py | 85 +++- .../builtin/agent_runner/transcript.py | 55 +++ .../entities/builtin/agent_runner/trigger.py | 23 +- .../api/proxies/agent_run_api.py | 139 +++++++ src/langbot_plugin/cli/run/handler.py | 1 + .../entities/io/actions/enums.py | 6 + src/langbot_plugin/runtime/plugin/mgr.py | 8 + .../agent_runner/test_context_result.py | 371 +++++++++++++----- .../test_history_event_entities.py | 234 +++++++++++ tests/api/proxies/test_agent_run_api_proxy.py | 8 + .../proxies/test_history_event_api_proxy.py | 224 +++++++++++ tests/runtime/plugin/test_mgr_agent_runner.py | 21 +- 26 files changed, 1821 insertions(+), 213 deletions(-) create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/bootstrap.py create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/context_access.py create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/context_policy.py create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/delivery.py create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/manifest.py create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/page_results.py create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/transcript.py create mode 100644 tests/api/entities/builtin/agent_runner/test_history_event_entities.py create mode 100644 tests/api/proxies/test_history_event_api_proxy.py diff --git a/src/langbot_plugin/api/definition/components/agent_runner/runner.py b/src/langbot_plugin/api/definition/components/agent_runner/runner.py index cc12311b..3ae52167 100644 --- a/src/langbot_plugin/api/definition/components/agent_runner/runner.py +++ b/src/langbot_plugin/api/definition/components/agent_runner/runner.py @@ -47,16 +47,23 @@ async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None # Get API proxy with run_id for LLM/tool/KB calls api = self.get_run_api(ctx) + # Get bootstrap messages if available (NOT core history) + # For full history, use ctx.context.available_apis.history_page + messages = ctx.bootstrap.messages if ctx.bootstrap else [] + + # Or build messages from current input + if not messages: + messages = [Message(role="user", content=ctx.input.to_text() or "")] + # Stream response from LLM (with run_id tracking) model_uuid = ctx.resources.models[0].model_id - messages = ctx.messages async for chunk in api.invoke_llm_stream(model_uuid, messages): - yield AgentRunResult.message_delta(chunk) + yield AgentRunResult.message_delta(ctx.run_id, chunk) # Final message final_message = Message(role="assistant", content="Hello world") - yield AgentRunResult.run_completed(message=final_message) + yield AgentRunResult.run_completed(ctx.run_id, message=final_message) ``` """ @@ -115,27 +122,31 @@ async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None Args: ctx: Agent run context containing: - - run_id: Unique ID for this run + - run_id: Unique ID for this run (REQUIRED for all AgentRunResult factories) - trigger: What triggered this run - conversation: Launcher/sender/bot/pipeline info - - event: Event envelope subset (for EBA) + - event: Event envelope subset (REQUIRED for Protocol v1) - actor: Who triggered the event - subject: What the event is about - - messages: Historical conversation messages - input: User input (text, contents, message_chain, attachments) + - delivery: Output surface capabilities (REQUIRED for Protocol v1) - resources: Authorized resources (models, tools, KBs, files, storage) + - context: ContextAccess - what's inlined, what APIs are available + - state: Host-managed scoped state snapshot - runtime: Host/environment info (version, query_id, trace_id, deadline) - config: Runner instance configuration + - bootstrap: Optional bootstrap messages (NOT core history) + - compatibility: Legacy compatibility fields Yields: AgentRunResult: Progress and final result events: - - message.delta: Streaming text chunk - - message.completed: Complete message - - tool.call.started: Tool call initiated - - tool.call.completed: Tool call finished - - state.updated: State change notification - - run.completed: Run finished successfully - - run.failed: Run failed with error + - message.delta: Streaming text chunk (use ctx.run_id) + - message.completed: Complete message (use ctx.run_id) + - tool.call.started: Tool call initiated (use ctx.run_id) + - tool.call.completed: Tool call finished (use ctx.run_id) + - state.updated: State change notification (use ctx.run_id) + - run.completed: Run finished successfully (use ctx.run_id) + - run.failed: Run failed with error (use ctx.run_id) - action.requested: Platform action request (future) Example: @@ -149,12 +160,12 @@ async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None model = ctx.resources.models[0] # Call LLM via plugin API... - # Stream response + # Stream response - NOTE: ctx.run_id is REQUIRED chunk = MessageChunk(role="assistant", content="Response") - yield AgentRunResult.message_delta(chunk) + yield AgentRunResult.message_delta(ctx.run_id, chunk) - # Complete - yield AgentRunResult.run_completed(finish_reason="stop") + # Complete - NOTE: ctx.run_id is REQUIRED + yield AgentRunResult.run_completed(ctx.run_id, finish_reason="stop") ``` """ pass diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py b/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py index cfc17e11..db168c07 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py @@ -6,8 +6,19 @@ from langbot_plugin.api.entities.builtin.agent_runner.permissions import ( AgentRunnerPermissions, ) +from langbot_plugin.api.entities.builtin.agent_runner.context_policy import ( + AgentRunnerContextPolicy, +) +from langbot_plugin.api.entities.builtin.agent_runner.manifest import ( + AgentRunnerManifest, + DynamicFormItemSchema, + I18nObject, +) from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger -from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.input import ( + AgentInput, + ArtifactRef, +) from langbot_plugin.api.entities.builtin.agent_runner.resources import ( AgentResources, ModelResource, @@ -26,8 +37,19 @@ AgentEventContext, ActorContext, SubjectContext, + RawEventRef, +) +from langbot_plugin.api.entities.builtin.agent_runner.context_access import ( + ContextAccess, + InlineContextPolicy, + ContextAPICapabilities, +) +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext +from langbot_plugin.api.entities.builtin.agent_runner.bootstrap import BootstrapContext +from langbot_plugin.api.entities.builtin.agent_runner.context import ( + AgentRunContext, + CompatibilityContext, ) -from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext from langbot_plugin.api.entities.builtin.agent_runner.result import ( AgentRunResult, AgentRunResultType, @@ -36,13 +58,26 @@ AgentRunReturn, create_legacy_context, ) +from langbot_plugin.api.entities.builtin.agent_runner.transcript import TranscriptItem +from langbot_plugin.api.entities.builtin.agent_runner.page_results import ( + HistoryPage, + HistorySearchResult, + AgentEventRecord, + EventPage, +) __all__ = [ - # v1 entities + # Manifest and policy + "AgentRunnerManifest", + "DynamicFormItemSchema", + "I18nObject", "AgentRunnerCapabilities", "AgentRunnerPermissions", + "AgentRunnerContextPolicy", + # Event and context "AgentTrigger", "AgentInput", + "ArtifactRef", "AgentResources", "ModelResource", "ToolResource", @@ -56,10 +91,25 @@ "AgentEventContext", "ActorContext", "SubjectContext", + "RawEventRef", + # Protocol v1 context access + "ContextAccess", + "InlineContextPolicy", + "ContextAPICapabilities", + "DeliveryContext", + "BootstrapContext", + "CompatibilityContext", + # Main context and result "AgentRunContext", "AgentRunResult", "AgentRunResultType", # Legacy (deprecated) "AgentRunReturn", "create_legacy_context", + # History and Event APIs + "TranscriptItem", + "HistoryPage", + "HistorySearchResult", + "AgentEventRecord", + "EventPage", ] \ No newline at end of file diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/bootstrap.py b/src/langbot_plugin/api/entities/builtin/agent_runner/bootstrap.py new file mode 100644 index 00000000..2de4f037 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/bootstrap.py @@ -0,0 +1,36 @@ +"""BootstrapContext as defined in Protocol v1. + +Bootstrap context is optional convenience provided by Host. +It is NOT the full history - it's a small bootstrap window. +""" + +from __future__ import annotations + +import typing +import pydantic + +from langbot_plugin.api.entities.builtin.provider.message import Message +from langbot_plugin.api.entities.builtin.agent_runner.input import ArtifactRef + + +class BootstrapContext(pydantic.BaseModel): + """Bootstrap context optionally provided by Host. + + Constraints: + - bootstrap.messages is Host convenience, NOT protocol core. + - Self-managed context runners should receive empty bootstrap or only current event. + - Host MUST NOT inline full transcript just to "help" the agent. + - Legacy max-round only affects compatibility adapter, NOT Protocol v1 fields. + """ + + messages: list[Message] = pydantic.Field(default_factory=list) + """Bootstrap messages (small window, not full history).""" + + summary: str | None = None + """Optional summary of earlier context.""" + + artifacts: list[ArtifactRef] = pydantic.Field(default_factory=list) + """Artifact references in bootstrap.""" + + metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Additional bootstrap metadata.""" diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/capabilities.py b/src/langbot_plugin/api/entities/builtin/agent_runner/capabilities.py index 133078a1..5db99462 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/capabilities.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/capabilities.py @@ -24,8 +24,8 @@ class AgentRunnerCapabilities(pydantic.BaseModel): multimodal_input: bool = False """Runner can process image/file/audio non-text input.""" - event_context: bool = False - """Runner will read ctx.event/actor/subject.""" + event_context: bool = True + """Runner will read ctx.event/actor/subject. Defaults True for Protocol v1.""" platform_api: bool = False """Runner may request platform actions (future feature, not executed this phase).""" @@ -35,3 +35,6 @@ class AgentRunnerCapabilities(pydantic.BaseModel): stateful_session: bool = False """Runner maintains external conversation/session state.""" + + self_managed_context: bool = True + """Runner manages its own working context; Host should not inline full history by default.""" diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/context.py b/src/langbot_plugin/api/entities/builtin/agent_runner/context.py index 6ef39992..d5eedb74 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/context.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/context.py @@ -17,26 +17,42 @@ ActorContext, SubjectContext, ) +from langbot_plugin.api.entities.builtin.agent_runner.context_access import ContextAccess +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext +from langbot_plugin.api.entities.builtin.agent_runner.bootstrap import BootstrapContext + + +class CompatibilityContext(pydantic.BaseModel): + """Compatibility context for legacy Query/Pipeline migration. + + This context holds legacy fields during migration from Query-first to event-first. + Runners SHOULD NOT depend on this for long-term capabilities. + """ + + query_id: int | None = None + """Legacy query ID.""" + + pipeline_uuid: str | None = None + """Legacy pipeline UUID.""" + + max_round: int | None = None + """Legacy max-round (for reference only, should NOT be used by new runners).""" + + legacy_messages: list[Message] = pydantic.Field(default_factory=list) + """Legacy messages field (prefer using bootstrap.messages or history API).""" + + extra: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Other legacy fields.""" class AgentRunContext(pydantic.BaseModel): """Agent run context passed to AgentRunner.run(). - Protocol v1 context structure. Contains: - - run_id: unique identifier for this run - - trigger: what triggered this run - - conversation: launcher/sender/bot/pipeline info - - event: event envelope subset (for future EBA) - - actor: who triggered the event - - subject: what the event is about - - prompt: effective prompt/instruction messages prepared by the host - - messages: historical conversation messages - - input: user input - - params: single-run public business parameters (read-only, non-persistent) - - resources: authorized resources - - state: host-managed scoped state snapshot (durable) - - runtime: host/environment info - - config: runner instance configuration + Protocol v1 context structure. This is event-first: + - event is REQUIRED (not optional) + - input is REQUIRED (current event input, not history) + - messages is DEMOTED to bootstrap (optional convenience) + - compatibility holds legacy Query/Pipeline fields Field boundaries: - config: Static runner configuration from pipeline/runner config. @@ -51,51 +67,30 @@ class AgentRunContext(pydantic.BaseModel): trigger: AgentTrigger """Trigger information.""" + event: AgentEventContext + """Event context (REQUIRED for Protocol v1).""" + conversation: ConversationContext | None = None """Conversation context.""" - event: AgentEventContext | None = None - """Event context (for EBA).""" - actor: ActorContext | None = None """Actor context.""" subject: SubjectContext | None = None """Subject context.""" - messages: list[Message] = pydantic.Field(default_factory=list) - """Historical messages in the conversation.""" - - prompt: list[Message] = pydantic.Field(default_factory=list) - """Effective prompt/instruction messages prepared by the host. - - This is the prompt after host-side preprocessing and prompt-related plugin - events have run. Runners should prefer this over static prompt data in - config when they need to call a model directly. - """ - input: AgentInput - """User input.""" + """User input (current event input, not history).""" - params: dict[str, typing.Any] = pydantic.Field(default_factory=dict) - """Single-run public business parameters. - - Semantics: - - JSON-safe, read-only for runner - - Non-persistent (not carried to next run) - - Not equivalent to LangBot query.variables - - Host should filter internal variables, secrets, permission control variables - - Use cases: - - Workflow inputs - - Prompt variables - - Pipeline pre-stage generated public business variables - - User-defined variables - """ + delivery: DeliveryContext + """Delivery context (output surface capabilities).""" resources: AgentResources """Authorized resources.""" + context: ContextAccess = pydantic.Field(default_factory=ContextAccess) + """Context access descriptor (what's inlined, what APIs are available).""" + state: AgentRunState = pydantic.Field(default_factory=AgentRunState) """Host-managed scoped state snapshot. @@ -115,7 +110,19 @@ class AgentRunContext(pydantic.BaseModel): """Runtime context.""" config: dict[str, typing.Any] = pydantic.Field(default_factory=dict) - """Runner instance configuration.""" + """Runner instance configuration (binding config from Host).""" + + bootstrap: BootstrapContext | None = None + """Optional bootstrap context (small convenience window, NOT full history).""" + + compatibility: CompatibilityContext | None = None + """Compatibility context for legacy Query/Pipeline fields. + + Runners SHOULD NOT depend on this for long-term capabilities. + """ + + metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Additional metadata.""" class Config: arbitrary_types_allowed = True diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/context_access.py b/src/langbot_plugin/api/entities/builtin/agent_runner/context_access.py new file mode 100644 index 00000000..89d0584c --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/context_access.py @@ -0,0 +1,96 @@ +"""ContextAccess and related entities as defined in Protocol v1. + +ContextAccess tells the runner what context Host has inlined and +what APIs are available for pulling more context. +""" + +from __future__ import annotations + +import typing +import pydantic + + +class InlineContextPolicy(pydantic.BaseModel): + """Describes what context Host has inlined.""" + + mode: typing.Literal["none", "current_event", "recent_tail", "summary_tail"] = "current_event" + """Inline mode used.""" + + delivered_count: int = 0 + """Number of items delivered.""" + + source_total_count: int | None = None + """Total items available from source.""" + + messages_complete: bool = False + """Whether all relevant messages are included.""" + + reason: str | None = None + """Reason for the policy (e.g., 'self_managed_context').""" + + +class ContextAPICapabilities(pydantic.BaseModel): + """Available context APIs for the runner.""" + + history_page: bool = False + """Whether history.page API is available.""" + + history_search: bool = False + """Whether history.search API is available.""" + + event_get: bool = False + """Whether events.get API is available.""" + + event_page: bool = False + """Whether events.page API is available.""" + + artifact_metadata: bool = False + """Whether artifacts.metadata API is available.""" + + artifact_read: bool = False + """Whether artifacts.read_range API is available.""" + + state: bool = False + """Whether state API is available.""" + + storage: bool = False + """Whether storage API is available.""" + + +class ContextAccess(pydantic.BaseModel): + """Context access descriptor for the runner. + + Tells the runner: + - Where the current event is in conversation/thread + - What cursor to use for pulling more history + - What Host has inlined vs not inlined + - What context APIs are available + """ + + conversation_id: str | None = None + """Current conversation ID.""" + + thread_id: str | None = None + """Current thread ID.""" + + latest_cursor: str | None = None + """Cursor for the latest event (use for history.page before_cursor).""" + + event_seq: int | None = None + """Current event sequence number.""" + + transcript_seq: int | None = None + """Current transcript sequence number.""" + + has_history_before: bool = False + """Whether there's history before the inlined content.""" + + inline_policy: InlineContextPolicy = pydantic.Field( + default_factory=InlineContextPolicy + ) + """What Host has inlined.""" + + available_apis: ContextAPICapabilities = pydantic.Field( + default_factory=ContextAPICapabilities + ) + """What context APIs are available.""" diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/context_policy.py b/src/langbot_plugin/api/entities/builtin/agent_runner/context_policy.py new file mode 100644 index 00000000..abab7bc4 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/context_policy.py @@ -0,0 +1,55 @@ +"""AgentRunner context policy as defined in Protocol v1. + +Context policy controls how Host should provide context to the runner. +""" + +from __future__ import annotations + +import typing +import pydantic + + +class AgentRunnerContextPolicy(pydantic.BaseModel): + """Context policy declared by an AgentRunner component. + + Host uses this declaration to decide whether/how to inline bootstrap history. + Default principle: Host MUST NOT inline full history by default. + """ + + ownership: typing.Literal["self_managed", "host_bootstrap", "hybrid"] = "self_managed" + """Context ownership mode. + + - self_managed: Host does not inline history, only provides event and handles. + - host_bootstrap: Host inlines a small window for simple runners. + - hybrid: Host inlines summary/tail, runner can still pull more. + """ + + bootstrap: typing.Literal["none", "current_event", "recent_tail", "summary_tail"] = "current_event" + """Bootstrap mode for context provisioning. + + - none: No bootstrap context. + - current_event: Only current event/input. + - recent_tail: Recent message tail. + - summary_tail: Summary with tail messages. + """ + + max_inline_events: int = 0 + """Maximum number of events to inline. 0 means no limit beyond bootstrap.""" + + max_inline_bytes: int = 0 + """Maximum bytes to inline. 0 means no limit beyond bootstrap.""" + + supports_history_pull: bool = True + """Whether runner can pull history via API.""" + + supports_history_search: bool = False + """Whether runner can search history.""" + + supports_artifact_pull: bool = True + """Whether runner can pull artifacts.""" + + owns_compaction: bool = True + """Runner owns context compaction. Host should not do semantic summarization.""" + + wants_static_context_refs: bool = True + """Host should use ref/hash for static content to reduce repeated payload.""" diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/delivery.py b/src/langbot_plugin/api/entities/builtin/agent_runner/delivery.py new file mode 100644 index 00000000..558ce1af --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/delivery.py @@ -0,0 +1,38 @@ +"""DeliveryContext as defined in Protocol v1. + +Delivery context describes the output surface and platform capabilities. +""" + +from __future__ import annotations + +import typing +import pydantic + + +class DeliveryContext(pydantic.BaseModel): + """Delivery context for the agent run. + + Tells the runner what output capabilities are available, + such as streaming, editing, reactions, and platform-specific features. + """ + + surface: str + """Output surface type (platform, webui, api, etc.).""" + + reply_target: dict[str, typing.Any] | None = None + """Target for reply (message_id, conversation_id, etc.).""" + + supports_streaming: bool = False + """Whether streaming output is supported.""" + + supports_edit: bool = False + """Whether message editing is supported.""" + + supports_reaction: bool = False + """Whether message reactions are supported.""" + + max_message_size: int | None = None + """Maximum message size in characters/bytes.""" + + platform_capabilities: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Platform-specific capabilities.""" diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/event.py b/src/langbot_plugin/api/entities/builtin/agent_runner/event.py index 1e6be84c..24fd3e99 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/event.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/event.py @@ -6,17 +6,30 @@ import pydantic +class RawEventRef(pydantic.BaseModel): + """Reference to raw event payload stored by Host. + + Large platform payloads should be stored as artifacts and referenced here. + """ + + artifact_id: str | None = None + """Artifact ID containing the raw payload.""" + + storage_key: str | None = None + """Storage key for raw payload (alternative to artifact).""" + + class ConversationContext(pydantic.BaseModel): """Conversation context for an agent run. Carries launcher/sender/bot/pipeline/history semantics. """ - session_id: str | None = None - """Session identifier.""" - conversation_id: str | None = None - """Conversation identifier.""" + """Stable conversation identifier.""" + + thread_id: str | None = None + """Thread ID within conversation (for platforms supporting threads).""" launcher_type: str | None = None """Launcher type (person, group).""" @@ -27,33 +40,52 @@ class ConversationContext(pydantic.BaseModel): sender_id: str | None = None """Sender ID.""" - bot_uuid: str | None = None + bot_id: str | None = None """Bot UUID.""" + workspace_id: str | None = None + """Workspace ID (for multi-tenant scenarios).""" + + # Legacy fields for backward compatibility + session_id: str | None = None + """Session identifier (legacy, prefer conversation_id).""" + pipeline_uuid: str | None = None - """Pipeline UUID.""" + """Pipeline UUID (legacy).""" class AgentEventContext(pydantic.BaseModel): - """Event envelope subset for EBA (Event-Based Architecture) support.""" + """Event envelope for EBA (Event-Based Architecture) support. + + Protocol v1 is event-first: event is a required field in AgentRunContext. + """ - event_type: str | None = None - """Event type.""" + event_id: str + """Unique event identifier.""" - event_id: str | None = None - """Event ID.""" + event_type: str + """Event type using stable protocol names (e.g., message.received).""" - event_timestamp: int | None = None - """Event timestamp.""" + event_time: int | None = None + """Event timestamp (epoch seconds).""" - event_data: dict[str, typing.Any] = pydantic.Field(default_factory=dict) - """Event payload.""" + source: str + """Event source (platform, webui, api, scheduler, system, pipeline_compat).""" + + source_event_type: str | None = None + """Original platform event type (for debugging/logging).""" + + raw_ref: RawEventRef | None = None + """Reference to raw event payload.""" + + data: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Event payload. Large data should be in raw_ref/artifacts.""" class ActorContext(pydantic.BaseModel): """Actor (who triggered the event) context.""" - actor_type: str | None = None + actor_type: str """Actor type (user, system, plugin).""" actor_id: str | None = None @@ -62,15 +94,18 @@ class ActorContext(pydantic.BaseModel): actor_name: str | None = None """Actor display name.""" + metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Additional actor metadata.""" + class SubjectContext(pydantic.BaseModel): """Subject (what the event is about) context.""" - subject_type: str | None = None + subject_type: str """Subject type (message, conversation, etc.).""" subject_id: str | None = None """Subject ID.""" - subject_data: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + data: dict[str, typing.Any] = pydantic.Field(default_factory=dict) """Subject data.""" diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/input.py b/src/langbot_plugin/api/entities/builtin/agent_runner/input.py index d82edec9..bc51fa1f 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/input.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/input.py @@ -8,10 +8,33 @@ from langbot_plugin.api.entities.builtin.provider.message import ContentElement +class ArtifactRef(pydantic.BaseModel): + """Reference to an artifact (file, image, tool result, etc.). + + Large content should be stored as artifacts and referenced here. + """ + + artifact_id: str + """Artifact identifier.""" + + artifact_type: str | None = None + """Artifact type (image, file, voice, tool_result, etc.).""" + + mime_type: str | None = None + """MIME type.""" + + size: int | None = None + """Size in bytes.""" + + name: str | None = None + """File name (if applicable).""" + + class AgentInput(pydantic.BaseModel): """Input for an agent run. Contains the user's input in multiple formats for convenience. + Protocol v1: input is required; attachments use ArtifactRef. """ text: str | None = None @@ -21,10 +44,10 @@ class AgentInput(pydantic.BaseModel): """Structured content elements (text, images, files, etc.).""" message_chain: list[dict[str, typing.Any]] | dict[str, typing.Any] | None = None - """Raw platform message chain (list of message components or dict representation).""" + """Raw platform message chain (compatibility field, not stable dependency).""" - attachments: list[dict[str, typing.Any]] = pydantic.Field(default_factory=list) - """File attachments metadata.""" + attachments: list[ArtifactRef] = pydantic.Field(default_factory=list) + """Artifact references for files/images/attachments.""" def to_text(self) -> str: """Extract plain text from input. diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/legacy.py b/src/langbot_plugin/api/entities/builtin/agent_runner/legacy.py index 3815399a..6b4c9283 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/legacy.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/legacy.py @@ -59,10 +59,14 @@ class AgentRunReturn(pydantic.BaseModel): class Config: arbitrary_types_allowed = True - def to_v1_result(self) -> AgentRunResult: + def to_v1_result(self, run_id: str = "legacy") -> AgentRunResult: """Convert legacy AgentRunReturn to v1 AgentRunResult. WARNING: This is a migration helper only. + + Args: + run_id: Run identifier (defaults to "legacy" for backward compatibility). + Callers should pass the actual run_id from context if available. """ warnings.warn( "AgentRunReturn is deprecated. Use AgentRunResult instead.", @@ -72,23 +76,23 @@ def to_v1_result(self) -> AgentRunResult: if self.type == "chunk": if self.message_chunk: - return AgentRunResult.message_delta(self.message_chunk) + return AgentRunResult.message_delta(run_id, self.message_chunk) elif self.content: # Create a simple chunk from content chunk = MessageChunk(role="assistant", content=self.content) - return AgentRunResult.message_delta(chunk) + return AgentRunResult.message_delta(run_id, chunk) else: return AgentRunResult.run_failed( - "Empty chunk content", "conversion.error" + run_id, "Empty chunk content", "conversion.error" ) elif self.type == "text": if self.content: message = Message(role="assistant", content=self.content) - return AgentRunResult.message_completed(message) + return AgentRunResult.message_completed(run_id, message) else: return AgentRunResult.run_failed( - "Empty text content", "conversion.error" + run_id, "Empty text content", "conversion.error" ) elif self.type == "tool_call": @@ -96,27 +100,31 @@ def to_v1_result(self) -> AgentRunResult: # Only report first tool call for legacy conversion tc = self.tool_calls[0] return AgentRunResult.tool_call_started( + run_id=run_id, tool_call_id=tc.id, tool_name=tc.function.name, parameters={"arguments": tc.function.arguments}, ) else: - return AgentRunResult.run_failed("Empty tool_calls", "conversion.error") + return AgentRunResult.run_failed(run_id, "Empty tool_calls", "conversion.error") elif self.type == "finish": if self.finish_reason == "error": return AgentRunResult.run_failed( + run_id=run_id, error=self.content or "Unknown error", code="runner.error", ) else: return AgentRunResult.run_completed( + run_id=run_id, message=self.message, finish_reason=self.finish_reason or "stop", ) else: return AgentRunResult.run_failed( + run_id=run_id, error=f"Unknown legacy type: {self.type}", code="conversion.error", ) @@ -237,6 +245,10 @@ def create_legacy_context( from langbot_plugin.api.entities.builtin.agent_runner.runtime import ( AgentRuntimeContext, ) + from langbot_plugin.api.entities.builtin.agent_runner.event import AgentEventContext + from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + from langbot_plugin.api.entities.builtin.agent_runner.bootstrap import BootstrapContext + from langbot_plugin.api.entities.builtin.agent_runner.context_access import ContextAccess runtime = AgentRuntimeContext( langbot_version=None, @@ -247,16 +259,50 @@ def create_legacy_context( metadata={}, ) + # Build event context (REQUIRED for Protocol v1) + event = AgentEventContext( + event_id=str(query_id), + event_type="message.received", + event_time=None, + source="pipeline", + source_event_type=None, + data={}, + ) + + # Build delivery context (REQUIRED for Protocol v1) + delivery = DeliveryContext( + surface="pipeline", + reply_target=None, + supports_streaming=False, + supports_edit=False, + supports_reaction=False, + max_message_size=None, + platform_capabilities={}, + ) + + # Build bootstrap context with historical messages + bootstrap = BootstrapContext( + messages=messages, + summary=None, + artifacts=[], + metadata={}, + ) + return AgentRunContext( run_id=f"run_{query_id}", trigger=trigger, conversation=conversation, - event=None, + event=event, # REQUIRED actor=None, subject=None, - messages=messages, input=agent_input, + delivery=delivery, # REQUIRED resources=resources, + context=ContextAccess(), # REQUIRED + state={}, runtime=runtime, config=extra_config, + bootstrap=bootstrap, # Historical messages go here + compatibility=None, + metadata={}, ) diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/manifest.py b/src/langbot_plugin/api/entities/builtin/agent_runner/manifest.py new file mode 100644 index 00000000..f612f253 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/manifest.py @@ -0,0 +1,96 @@ +"""AgentRunner manifest as defined in Protocol v1. + +The manifest describes an AgentRunner component's metadata, +capabilities, permissions, and context policy. +""" + +from __future__ import annotations + +import typing +import pydantic + +from langbot_plugin.api.entities.builtin.agent_runner.capabilities import ( + AgentRunnerCapabilities, +) +from langbot_plugin.api.entities.builtin.agent_runner.permissions import ( + AgentRunnerPermissions, +) +from langbot_plugin.api.entities.builtin.agent_runner.context_policy import ( + AgentRunnerContextPolicy, +) + + +# I18n object: maps locale code to localized string +I18nObject = dict[str, str] + + +class DynamicFormItemSchema(pydantic.BaseModel): + """Schema for a dynamic form configuration item. + + Represents a form field in the runner's config schema. + """ + + type: str + """Field type (text, select, llm-model-selector, etc.).""" + + name: str + """Field name/key.""" + + label: I18nObject = pydantic.Field(default_factory=dict) + """Localized label.""" + + description: I18nObject | None = None + """Localized description.""" + + required: bool = False + """Whether the field is required.""" + + default: typing.Any = None + """Default value.""" + + options: list[dict[str, typing.Any]] | None = None + """Options for select/radio types.""" + + # Allow additional properties for form item flexibility + model_config = pydantic.ConfigDict(extra="allow") + + +class AgentRunnerManifest(pydantic.BaseModel): + """Manifest describing an AgentRunner component. + + This is the stable descriptor returned during LIST_AGENT_RUNNERS. + Contains metadata, capabilities, permissions, and config schema. + """ + + id: str + """Unique runner ID. Recommended format: plugin:author/plugin_name/runner_name.""" + + name: str + """Runner name within the plugin (e.g., 'default').""" + + label: I18nObject + """Localized display name.""" + + description: I18nObject | None = None + """Localized description.""" + + capabilities: AgentRunnerCapabilities = pydantic.Field( + default_factory=AgentRunnerCapabilities + ) + """Runner capabilities.""" + + permissions: AgentRunnerPermissions = pydantic.Field( + default_factory=AgentRunnerPermissions + ) + """Runner permissions.""" + + context: AgentRunnerContextPolicy = pydantic.Field( + default_factory=AgentRunnerContextPolicy + ) + """Context policy.""" + + config_schema: list[DynamicFormItemSchema] = pydantic.Field(default_factory=list) + """Configuration form schema for binding config.""" + + metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Additional metadata for display, diagnostics, non-stable extensions.""" diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/page_results.py b/src/langbot_plugin/api/entities/builtin/agent_runner/page_results.py new file mode 100644 index 00000000..3193d913 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/page_results.py @@ -0,0 +1,146 @@ +"""History and Event page result entities.""" +from __future__ import annotations + +import typing +import pydantic + +from .transcript import TranscriptItem + + +class HistoryPage(pydantic.BaseModel): + """Paged result for history.page API. + + Returns Transcript items ordered by sequence/cursor. + Used by AgentRunner to pull conversation history. + """ + + items: list[TranscriptItem] = pydantic.Field(default_factory=list) + """Transcript items in this page.""" + + next_cursor: str | None = None + """Cursor for the next page (forward direction).""" + + prev_cursor: str | None = None + """Cursor for the previous page (backward direction).""" + + has_more: bool = False + """Whether more items are available.""" + + total_count: int | None = None + """Total count if available (may be None for large conversations).""" + + model_config = pydantic.ConfigDict(extra='forbid') + + +class HistorySearchResult(pydantic.BaseModel): + """Result for history.search API. + + Returns matching transcript items ranked by relevance. + Basic implementation may use simple LIKE filtering. + """ + + items: list[TranscriptItem] = pydantic.Field(default_factory=list) + """Matching transcript items.""" + + total_count: int | None = None + """Total matching count if available.""" + + query: str + """The search query that was executed.""" + + model_config = pydantic.ConfigDict(extra='forbid') + + +class AgentEventRecord(pydantic.BaseModel): + """Event record returned by event.get and event.page APIs. + + This is a stable, auditable representation of events stored in EventLog. + It does not include large raw payloads; use artifact refs for those. + """ + + event_id: str + """Unique event identifier.""" + + event_type: str + """Event type (message.received, tool.call.started, etc.).""" + + event_time: int | None = None + """Unix timestamp when the event occurred.""" + + source: str + """Event source (platform, webui, api, scheduler, system).""" + + bot_id: str | None = None + """Bot UUID that handled this event.""" + + workspace_id: str | None = None + """Workspace ID for multi-tenant deployments.""" + + conversation_id: str | None = None + """Conversation ID this event belongs to.""" + + thread_id: str | None = None + """Thread ID if applicable.""" + + actor_type: str | None = None + """Actor type (user, system, runner).""" + + actor_id: str | None = None + """Actor identifier.""" + + actor_name: str | None = None + """Actor display name.""" + + subject_type: str | None = None + """Subject type (message, tool_call, artifact).""" + + subject_id: str | None = None + """Subject identifier.""" + + input_summary: str | None = None + """Brief summary of input (truncated text).""" + + input_ref: str | None = None + """Reference to full input artifact if large.""" + + raw_ref: str | None = None + """Reference to raw event payload in ArtifactStore.""" + + seq: int | None = None + """Sequence number for pagination.""" + + cursor: str | None = None + """Cursor string for pagination.""" + + created_at: int | None = None + """Unix timestamp when the record was created.""" + + metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Additional metadata.""" + + model_config = pydantic.ConfigDict(extra='forbid') + + +class EventPage(pydantic.BaseModel): + """Paged result for event.page API. + + Returns event records ordered by sequence/cursor. + Used by AgentRunner to access non-message events. + """ + + items: list[AgentEventRecord] = pydantic.Field(default_factory=list) + """Event records in this page.""" + + next_cursor: str | None = None + """Cursor for the next page.""" + + prev_cursor: str | None = None + """Cursor for the previous page.""" + + has_more: bool = False + """Whether more items are available.""" + + total_count: int | None = None + """Total count if available.""" + + model_config = pydantic.ConfigDict(extra='forbid') diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/permissions.py b/src/langbot_plugin/api/entities/builtin/agent_runner/permissions.py index 6da2a2fc..0e3c289e 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/permissions.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/permissions.py @@ -15,12 +15,12 @@ class AgentRunnerPermissions(pydantic.BaseModel): produce ctx.resources. """ - models: list[typing.Literal["list", "invoke", "stream", "embedding"]] = ( + models: list[typing.Literal["invoke", "stream", "rerank"]] = ( pydantic.Field(default_factory=list) ) """Model operations allowed.""" - tools: list[typing.Literal["list", "detail", "call"]] = pydantic.Field( + tools: list[typing.Literal["detail", "call"]] = pydantic.Field( default_factory=list ) """Tool operations allowed.""" @@ -30,7 +30,22 @@ class AgentRunnerPermissions(pydantic.BaseModel): ) """Knowledge base operations allowed.""" - storage: list[typing.Literal["plugin", "workspace"]] = pydantic.Field( + history: list[typing.Literal["page", "search"]] = pydantic.Field( + default_factory=list + ) + """History operations allowed.""" + + events: list[typing.Literal["get", "page"]] = pydantic.Field( + default_factory=list + ) + """Event operations allowed.""" + + artifacts: list[typing.Literal["metadata", "read"]] = pydantic.Field( + default_factory=list + ) + """Artifact operations allowed.""" + + storage: list[typing.Literal["plugin", "workspace", "binding"]] = pydantic.Field( default_factory=list ) """Storage scopes allowed.""" diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/result.py b/src/langbot_plugin/api/entities/builtin/agent_runner/result.py index f383da21..c4a64c43 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/result.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/result.py @@ -18,42 +18,69 @@ class AgentRunResultType(str, enum.Enum): TOOL_CALL_STARTED = "tool.call.started" TOOL_CALL_COMPLETED = "tool.call.completed" STATE_UPDATED = "state.updated" + ARTIFACT_CREATED = "artifact.created" + ACTION_REQUESTED = "action.requested" RUN_COMPLETED = "run.completed" RUN_FAILED = "run.failed" - ACTION_REQUESTED = "action.requested" class AgentRunResult(pydantic.BaseModel): """Result event from AgentRunner.run(). + Protocol v1 result structure: + - run_id: Links result to the run + - type: Result type + - data: Type-specific payload + - sequence: Optional sequence number for ordering + - timestamp: Optional timestamp + Each yield from the runner's run() method produces one AgentRunResult. LangBot maps these to appropriate pipeline events. """ + run_id: str + """Run identifier linking this result to the run.""" + type: AgentRunResultType """Result type.""" data: dict[str, typing.Any] = pydantic.Field(default_factory=dict) """Result data.""" + sequence: int | None = None + """Optional sequence number for ordering.""" + + timestamp: int | None = None + """Optional timestamp (epoch seconds).""" + @classmethod - def message_delta(cls, chunk: MessageChunk) -> "AgentRunResult": + def message_delta( + cls, + run_id: str, + chunk: MessageChunk, + ) -> "AgentRunResult": """Create a message.delta result. LangBot maps this to MessageChunk for streaming output. """ return cls( + run_id=run_id, type=AgentRunResultType.MESSAGE_DELTA, data={"chunk": chunk.model_dump(mode="json")}, ) @classmethod - def message_completed(cls, message: Message) -> "AgentRunResult": + def message_completed( + cls, + run_id: str, + message: Message, + ) -> "AgentRunResult": """Create a message.completed result. LangBot maps this to a complete Message. """ return cls( + run_id=run_id, type=AgentRunResultType.MESSAGE_COMPLETED, data={"message": message.model_dump(mode="json")}, ) @@ -61,6 +88,7 @@ def message_completed(cls, message: Message) -> "AgentRunResult": @classmethod def tool_call_started( cls, + run_id: str, tool_call_id: str, tool_name: str, parameters: dict[str, typing.Any], @@ -70,6 +98,7 @@ def tool_call_started( LangBot records this for telemetry/debug. """ return cls( + run_id=run_id, type=AgentRunResultType.TOOL_CALL_STARTED, data={ "tool_call_id": tool_call_id, @@ -81,6 +110,7 @@ def tool_call_started( @classmethod def tool_call_completed( cls, + run_id: str, tool_call_id: str, tool_name: str, result: dict[str, typing.Any] | None = None, @@ -91,6 +121,7 @@ def tool_call_completed( LangBot records this for telemetry/debug. """ return cls( + run_id=run_id, type=AgentRunResultType.TOOL_CALL_COMPLETED, data={ "tool_call_id": tool_call_id, @@ -100,9 +131,36 @@ def tool_call_completed( }, ) + @classmethod + def artifact_created( + cls, + run_id: str, + artifact_id: str, + artifact_type: str, + mime_type: str | None = None, + size: int | None = None, + name: str | None = None, + ) -> "AgentRunResult": + """Create an artifact.created result. + + Runner created an artifact. + """ + return cls( + run_id=run_id, + type=AgentRunResultType.ARTIFACT_CREATED, + data={ + "artifact_id": artifact_id, + "artifact_type": artifact_type, + "mime_type": mime_type, + "size": size, + "name": name, + }, + ) + @classmethod def state_updated( cls, + run_id: str, key: str, value: typing.Any, scope: STATE_SCOPE_LITERAL = "conversation", @@ -113,6 +171,7 @@ def state_updated( SDK defines the protocol; LangBot host handles actual persistence. Args: + run_id: Run identifier key: State key, should use namespace prefix (e.g., external.conversation_id) value: State value, must be JSON-serializable scope: State scope - one of: conversation, actor, subject, runner. @@ -127,13 +186,14 @@ def state_updated( Example: # Store external platform conversation ID yield AgentRunResult.state_updated( + run_id, "external.conversation_id", "abc123", scope="conversation" ) # Store user preference (backward compatible) - yield AgentRunResult.state_updated("preferred_language", "en") + yield AgentRunResult.state_updated(run_id, "preferred_language", "en") """ if scope not in VALID_STATE_SCOPES: raise ValueError( @@ -141,6 +201,7 @@ def state_updated( ) return cls( + run_id=run_id, type=AgentRunResultType.STATE_UPDATED, data={"scope": scope, "key": key, "value": value}, ) @@ -148,6 +209,7 @@ def state_updated( @classmethod def run_completed( cls, + run_id: str, message: Message | None = None, finish_reason: str = "stop", ) -> "AgentRunResult": @@ -159,11 +221,12 @@ def run_completed( data: dict[str, typing.Any] = {"finish_reason": finish_reason} if message is not None: data["message"] = message.model_dump(mode="json") - return cls(type=AgentRunResultType.RUN_COMPLETED, data=data) + return cls(run_id=run_id, type=AgentRunResultType.RUN_COMPLETED, data=data) @classmethod def run_failed( cls, + run_id: str, error: str, code: str | None = None, retryable: bool = False, @@ -173,6 +236,7 @@ def run_failed( LangBot returns user-friendly error message per pipeline error strategy. """ return cls( + run_id=run_id, type=AgentRunResultType.RUN_FAILED, data={ "error": error, @@ -184,14 +248,21 @@ def run_failed( @classmethod def action_requested( cls, + run_id: str, action: str, - parameters: dict[str, typing.Any], + target: dict[str, typing.Any] | None = None, + payload: dict[str, typing.Any] | None = None, ) -> "AgentRunResult": """Create an action.requested result. This phase only logs to telemetry, actual execution waits for EBA. """ return cls( + run_id=run_id, type=AgentRunResultType.ACTION_REQUESTED, - data={"action": action, "parameters": parameters}, + data={ + "action": action, + "target": target, + "payload": payload, + }, ) \ No newline at end of file diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/transcript.py b/src/langbot_plugin/api/entities/builtin/agent_runner/transcript.py new file mode 100644 index 00000000..39c5a8e0 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/transcript.py @@ -0,0 +1,55 @@ +"""Transcript item entity for history projection.""" +from __future__ import annotations + +import typing +import pydantic + + +class TranscriptItem(pydantic.BaseModel): + """A single item in the transcript history projection. + + Transcript is the conversation-oriented view of events, designed for + agent history retrieval and UI display. It does not include raw platform + payloads or large artifacts. + """ + + transcript_id: str + """Unique transcript item identifier.""" + + event_id: str + """Reference to the source event in EventLog.""" + + conversation_id: str | None = None + """Conversation this item belongs to.""" + + thread_id: str | None = None + """Thread ID if platform supports threads.""" + + role: str + """Message role: 'user', 'assistant', 'system', or 'tool'.""" + + item_type: str = "message" + """Item type: 'message', 'tool_call', 'tool_result', 'system'.""" + + content: str | None = None + """Text content summary (may be truncated for large messages).""" + + content_json: dict[str, typing.Any] | None = None + """Full structured content if available (Message model dump).""" + + artifact_refs: list[dict[str, typing.Any]] = pydantic.Field(default_factory=list) + """References to artifacts (images, files) attached to this item.""" + + seq: int | None = None + """Sequence number within conversation (for cursor-based pagination).""" + + cursor: str | None = None + """Cursor string for pagination (derived from seq).""" + + created_at: int | None = None + """Unix timestamp when the item was created.""" + + metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Additional metadata (sender_id, platform, etc.).""" + + model_config = pydantic.ConfigDict(extra='forbid') diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/trigger.py b/src/langbot_plugin/api/entities/builtin/agent_runner/trigger.py index e9f72314..57e9585d 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/trigger.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/trigger.py @@ -13,10 +13,25 @@ class AgentTrigger(pydantic.BaseModel): """ type: str - """Trigger type, e.g., 'message.received'.""" - - source: typing.Literal["pipeline", "event_router"] = "pipeline" - """Source of the trigger.""" + """Trigger type, e.g., 'message.received'. Should match event.event_type or coarser.""" + + source: typing.Literal[ + "platform", + "webui", + "api", + "scheduler", + "system", + "pipeline_compat", + ] = "pipeline_compat" + """Source of the trigger. + + - platform: Direct platform event + - webui: WebUI debug chat + - api: API trigger + - scheduler: Scheduled trigger + - system: System event + - pipeline_compat: Pipeline compatibility adapter + """ timestamp: int | None = None """Trigger timestamp (epoch seconds).""" diff --git a/src/langbot_plugin/api/proxies/agent_run_api.py b/src/langbot_plugin/api/proxies/agent_run_api.py index c1eb7113..d7002dec 100644 --- a/src/langbot_plugin/api/proxies/agent_run_api.py +++ b/src/langbot_plugin/api/proxies/agent_run_api.py @@ -504,3 +504,142 @@ async def invoke_rerank( timeout=effective_timeout, ) return resp.get("results", []) + + # ================= History APIs (run-scoped, conversation-scoped) ================= + + async def history_page( + self, + conversation_id: str | None = None, + before_cursor: str | None = None, + after_cursor: str | None = None, + limit: int = 50, + direction: str = "backward", + include_artifacts: bool = False, + ) -> dict[str, Any]: + """Page through transcript history for a conversation. + + Args: + conversation_id: Conversation ID to query. Must match current run's + conversation. If None, uses current run's conversation. + before_cursor: Get items before this cursor (backward direction). + after_cursor: Get items after this cursor (forward direction). + limit: Maximum items to return. Has a hard cap on host side. + direction: 'backward' (older items) or 'forward' (newer items). + include_artifacts: Whether to include artifact refs in items. + + Returns: + HistoryPage as dict with items, next_cursor, prev_cursor, has_more. + + Raises: + PermissionDeniedError: If not authorized for this conversation. + """ + timeout = self._bounded_timeout(default=30.0) + resp = await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.HISTORY_PAGE, + { + "run_id": self.run_id, + "conversation_id": conversation_id, + "before_cursor": before_cursor, + "after_cursor": after_cursor, + "limit": limit, + "direction": direction, + "include_artifacts": include_artifacts, + }, + timeout, + ) + return resp + + async def history_search( + self, + query: str, + filters: dict[str, Any] | None = None, + top_k: int = 10, + ) -> dict[str, Any]: + """Search transcript history for matching items. + + This is a basic search capability. Host implementation may use + simple LIKE filtering initially. + + Args: + query: Search query string. + filters: Optional filters (conversation_id, event_types, etc.). + top_k: Maximum results to return. + + Returns: + HistorySearchResult as dict with items, total_count, query. + + Note: + Basic implementation may return unsupported error or limited results. + """ + timeout = self._bounded_timeout(default=30.0) + resp = await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.HISTORY_SEARCH, + { + "run_id": self.run_id, + "query": query, + "filters": filters or {}, + "top_k": top_k, + }, + timeout, + ) + return resp + + # ================= Event APIs (run-scoped) ================= + + async def event_get(self, event_id: str) -> dict[str, Any]: + """Get a single event record by ID. + + Args: + event_id: The event ID to retrieve. + + Returns: + AgentEventRecord as dict. + + Raises: + PermissionDeniedError: If event not accessible by current run. + """ + timeout = self._bounded_timeout(default=15.0) + resp = await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.EVENT_GET, + { + "run_id": self.run_id, + "event_id": event_id, + }, + timeout, + ) + return resp + + async def event_page( + self, + conversation_id: str | None = None, + event_types: list[str] | None = None, + before_cursor: str | None = None, + limit: int = 50, + ) -> dict[str, Any]: + """Page through event records. + + Args: + conversation_id: Conversation ID to query. Must match current run. + event_types: Filter by event types if specified. + before_cursor: Get items before this cursor. + limit: Maximum items to return. Has a hard cap on host side. + + Returns: + EventPage as dict with items, next_cursor, prev_cursor, has_more. + + Raises: + PermissionDeniedError: If not authorized for this conversation. + """ + timeout = self._bounded_timeout(default=30.0) + resp = await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.EVENT_PAGE, + { + "run_id": self.run_id, + "conversation_id": conversation_id, + "event_types": event_types, + "before_cursor": before_cursor, + "limit": limit, + }, + timeout, + ) + return resp diff --git a/src/langbot_plugin/cli/run/handler.py b/src/langbot_plugin/cli/run/handler.py index c81f58eb..62bcf356 100644 --- a/src/langbot_plugin/cli/run/handler.py +++ b/src/langbot_plugin/cli/run/handler.py @@ -98,6 +98,7 @@ async def _iter_runner_results_with_deadline( yield result except asyncio.TimeoutError: yield AgentRunResult.run_failed( + run_id=run_context.run_id, error="Agent runner timed out", code="runner.timeout", retryable=True, diff --git a/src/langbot_plugin/entities/io/actions/enums.py b/src/langbot_plugin/entities/io/actions/enums.py index fd9806f3..0b9eb58a 100644 --- a/src/langbot_plugin/entities/io/actions/enums.py +++ b/src/langbot_plugin/entities/io/actions/enums.py @@ -79,6 +79,12 @@ class PluginToRuntimeAction(ActionType): LIST_PIPELINE_KNOWLEDGE_BASES = "list_pipeline_knowledge_bases" RETRIEVE_KNOWLEDGE_BASE = "retrieve_knowledge_base" + """Agent History/Event APIs (run-scoped, requires run_id authorization)""" + HISTORY_PAGE = "history_page" + HISTORY_SEARCH = "history_search" + EVENT_GET = "event_get" + EVENT_PAGE = "event_page" + class RuntimeToPluginAction(ActionType): """The action from runtime to plugin.""" diff --git a/src/langbot_plugin/runtime/plugin/mgr.py b/src/langbot_plugin/runtime/plugin/mgr.py index baa524a4..eeaf837c 100644 --- a/src/langbot_plugin/runtime/plugin/mgr.py +++ b/src/langbot_plugin/runtime/plugin/mgr.py @@ -932,6 +932,9 @@ async def run_agent( AgentRunResult, ) + # Extract run_id from context for error responses + run_id = context.get("run_id", "unknown") + # Find the plugin target_plugin = None for plugin in self.plugins: @@ -944,6 +947,7 @@ async def run_agent( if target_plugin is None: yield AgentRunResult.run_failed( + run_id=run_id, error=f"Plugin {plugin_author}/{plugin_name} not found", code="runner.plugin_not_found", ).model_dump(mode="json") @@ -961,6 +965,7 @@ async def run_agent( if target_component is None: yield AgentRunResult.run_failed( + run_id=run_id, error=f"AgentRunner {runner_name} not found in plugin {plugin_author}/{plugin_name}", code="runner.not_found", ).model_dump(mode="json") @@ -969,6 +974,7 @@ async def run_agent( # Check if plugin handler exists for forwarding if target_plugin._runtime_plugin_handler is None: yield AgentRunResult.run_failed( + run_id=run_id, error=f"Plugin {plugin_author}/{plugin_name} has no runtime handler", code="runner.handler_not_found", ).model_dump(mode="json") @@ -996,6 +1002,7 @@ async def run_agent( except (asyncio.TimeoutError, ActionCallTimeoutError): yield AgentRunResult.run_failed( + run_id=run_id, error="Agent runner timed out", code="runner.timeout", retryable=True, @@ -1004,6 +1011,7 @@ async def run_agent( import traceback traceback.print_exc() yield AgentRunResult.run_failed( + run_id=run_id, error=f"Error forwarding to plugin: {e}", code="runner.forward_exception", ).model_dump(mode="json") diff --git a/tests/api/entities/builtin/agent_runner/test_context_result.py b/tests/api/entities/builtin/agent_runner/test_context_result.py index f292ee9c..486e6c0a 100644 --- a/tests/api/entities/builtin/agent_runner/test_context_result.py +++ b/tests/api/entities/builtin/agent_runner/test_context_result.py @@ -5,7 +5,10 @@ import pytest import pydantic -from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext +from langbot_plugin.api.entities.builtin.agent_runner.context import ( + AgentRunContext, + CompatibilityContext, +) from langbot_plugin.api.entities.builtin.agent_runner.result import ( AgentRunResult, AgentRunResultType, @@ -35,6 +38,19 @@ ActorContext, SubjectContext, ) +from langbot_plugin.api.entities.builtin.agent_runner.context_access import ( + ContextAccess, + InlineContextPolicy, + ContextAPICapabilities, +) +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext +from langbot_plugin.api.entities.builtin.agent_runner.bootstrap import BootstrapContext +from langbot_plugin.api.entities.builtin.agent_runner.context_policy import ( + AgentRunnerContextPolicy, +) +from langbot_plugin.api.entities.builtin.agent_runner.manifest import ( + AgentRunnerManifest, +) from langbot_plugin.api.entities.builtin.provider.message import ( Message, MessageChunk, @@ -47,15 +63,23 @@ class TestAgentRunContextV1: def test_minimal_context_validate(self): """Test minimal required fields validation.""" - trigger = AgentTrigger(type="message.received", source="pipeline") + trigger = AgentTrigger(type="message.received", source="pipeline_compat") + event = AgentEventContext( + event_id="evt_1", + event_type="message.received", + source="platform", + ) input = AgentInput(text="Hello") resources = AgentResources() runtime = AgentRuntimeContext() + delivery = DeliveryContext(surface="platform") ctx = AgentRunContext( run_id="run_123", trigger=trigger, + event=event, input=input, + delivery=delivery, resources=resources, runtime=runtime, ) @@ -63,96 +87,99 @@ def test_minimal_context_validate(self): assert ctx.run_id == "run_123" assert ctx.trigger.type == "message.received" assert ctx.input.text == "Hello" - assert ctx.messages == [] - assert ctx.prompt == [] + assert ctx.bootstrap is None # Optional, not required assert ctx.config == {} - # New fields: params and state have defaults - assert ctx.params == {} - assert ctx.state.conversation == {} - assert ctx.state.actor == {} - assert ctx.state.subject == {} - assert ctx.state.runner == {} - - def test_params_default_empty_dict(self): - """Test params defaults to empty dict.""" + assert ctx.context is not None # Has default + + def test_event_is_required(self): + """Test that event is required for Protocol v1.""" trigger = AgentTrigger(type="message.received") input = AgentInput(text="Hello") resources = AgentResources() runtime = AgentRuntimeContext() + delivery = DeliveryContext(surface="platform") - ctx = AgentRunContext( - run_id="run_params", - trigger=trigger, - input=input, - resources=resources, - runtime=runtime, - ) - - assert ctx.params == {} - assert isinstance(ctx.params, dict) + # Missing event should raise validation error + with pytest.raises(pydantic.ValidationError): + AgentRunContext( + run_id="run_123", + trigger=trigger, + # event missing - should fail + input=input, + delivery=delivery, + resources=resources, + runtime=runtime, + ) - def test_state_default_all_scopes_empty(self): - """Test state defaults with all scopes empty.""" + def test_messages_in_bootstrap_not_top_level(self): + """Test that messages are in bootstrap, not top-level context.""" trigger = AgentTrigger(type="message.received") + event = AgentEventContext( + event_id="evt_1", + event_type="message.received", + source="platform", + ) input = AgentInput(text="Hello") resources = AgentResources() runtime = AgentRuntimeContext() + delivery = DeliveryContext(surface="platform") + bootstrap = BootstrapContext( + messages=[Message(role="user", content="Hi")], + ) ctx = AgentRunContext( - run_id="run_state", + run_id="run_123", trigger=trigger, + event=event, input=input, + delivery=delivery, resources=resources, runtime=runtime, + bootstrap=bootstrap, ) - assert ctx.state.conversation == {} - assert ctx.state.actor == {} - assert ctx.state.subject == {} - assert ctx.state.runner == {} + # messages are in bootstrap + assert ctx.bootstrap is not None + assert len(ctx.bootstrap.messages) == 1 - def test_params_and_state_from_dict(self): - """Test constructing params and state from dict.""" - data = { - "run_id": "run_dict", - "trigger": {"type": "message.received", "source": "pipeline"}, - "input": {"text": "Hello"}, - "resources": {}, - "runtime": {}, - "params": {"workflow_input": "value1", "prompt_var": "value2"}, - "state": { - "conversation": {"external.conversation_id": "conv_abc"}, - "actor": {"preferred_language": "en"}, - "subject": {}, - "runner": {}, - }, - } + def test_context_access_default(self): + """Test ContextAccess default values.""" + context_access = ContextAccess() - ctx = AgentRunContext.model_validate(data) + assert context_access.conversation_id is None + assert context_access.has_history_before is False + assert context_access.inline_policy.mode == "current_event" - assert ctx.params["workflow_input"] == "value1" - assert ctx.params["prompt_var"] == "value2" - assert ctx.state.conversation["external.conversation_id"] == "conv_abc" - assert ctx.state.actor["preferred_language"] == "en" + def test_compatibility_context(self): + """Test CompatibilityContext for legacy fields.""" + compat = CompatibilityContext( + query_id=123, + pipeline_uuid="pipe-123", + max_round=10, + ) + + assert compat.query_id == 123 + assert compat.max_round == 10 def test_full_context_validate(self): """Test full context with all optional fields.""" trigger = AgentTrigger( - type="message.received", source="pipeline", timestamp=1234567890 + type="message.received", source="pipeline_compat", timestamp=1234567890 ) conversation = ConversationContext( - session_id="sess_1", conversation_id="conv_1", + thread_id="thread_1", launcher_type="person", launcher_id="12345", sender_id="user_1", - bot_uuid="bot_123", - pipeline_uuid="pipe_123", + bot_id="bot_123", + workspace_id="ws_1", ) event = AgentEventContext( - event_type="message", event_id="evt_1", - event_timestamp=1234567890, + event_type="message.received", + event_time=1234567890, + source="platform", ) actor = ActorContext( actor_type="user", @@ -163,21 +190,16 @@ def test_full_context_validate(self): subject_type="message", subject_id="msg_1", ) - messages = [ - Message(role="user", content="Hi"), - Message(role="assistant", content="Hello"), - ] - prompt = [ - Message(role="system", content="You are helpful."), - ] + bootstrap = BootstrapContext( + messages=[ + Message(role="user", content="Hi"), + Message(role="assistant", content="Hello"), + ], + ) input = AgentInput( text="What's up?", contents=[ContentElement(type="text", text="What's up?")], ) - params = { - "workflow_input": "test_workflow", - "custom_var": 42, - } resources = AgentResources( models=[ModelResource(model_id="gpt-4", model_type="chat")], tools=[ToolResource(tool_name="search", tool_type="function")], @@ -189,10 +211,17 @@ def test_full_context_validate(self): ) runtime = AgentRuntimeContext( langbot_version="1.0.0", - query_id=123, trace_id="trace_abc", deadline_at=1234568000, ) + delivery = DeliveryContext( + surface="platform", + supports_streaming=True, + ) + context_access = ContextAccess( + conversation_id="conv_1", + has_history_before=True, + ) ctx = AgentRunContext( run_id="run_full", @@ -201,46 +230,51 @@ def test_full_context_validate(self): event=event, actor=actor, subject=subject, - messages=messages, - prompt=prompt, input=input, - params=params, + delivery=delivery, resources=resources, + context=context_access, state=state, runtime=runtime, config={"model": "gpt-4"}, + bootstrap=bootstrap, ) assert ctx.run_id == "run_full" assert ctx.conversation.launcher_type == "person" assert ctx.resources.models[0].model_id == "gpt-4" - assert ctx.prompt[0].content == "You are helpful." - assert len(ctx.messages) == 2 + assert ctx.bootstrap is not None + assert len(ctx.bootstrap.messages) == 2 assert ctx.config["model"] == "gpt-4" - assert ctx.params["workflow_input"] == "test_workflow" - assert ctx.state.conversation["external.conversation_id"] == "conv_xyz" - assert ctx.state.actor["memory.summary"] == "User likes coffee" + assert ctx.context.has_history_before is True def test_context_missing_required_field(self): """Test that missing required fields raise validation error.""" with pytest.raises(pydantic.ValidationError): AgentRunContext( - # Missing run_id, trigger, input, resources, runtime + # Missing run_id, trigger, event, input, delivery, resources, runtime ) def test_context_model_validate_from_dict(self): """Test model_validate from dict (as LangBot will send).""" data = { "run_id": "run_dict", - "trigger": {"type": "message.received", "source": "pipeline"}, + "trigger": {"type": "message.received", "source": "pipeline_compat"}, + "event": { + "event_id": "evt_1", + "event_type": "message.received", + "source": "platform", + }, "input": {"text": "Hello from dict"}, + "delivery": {"surface": "platform"}, "resources": {}, - "runtime": {"sdk_protocol_version": "1"}, + "runtime": {}, } ctx = AgentRunContext.model_validate(data) assert ctx.run_id == "run_dict" assert ctx.input.text == "Hello from dict" + assert ctx.event.event_type == "message.received" class TestAgentRunState: @@ -292,8 +326,9 @@ class TestAgentRunResultV1: def test_message_delta_validate(self): """Test message.delta result.""" chunk = MessageChunk(role="assistant", content="Hello") - result = AgentRunResult.message_delta(chunk) + result = AgentRunResult.message_delta("run_1", chunk) + assert result.run_id == "run_1" assert result.type == AgentRunResultType.MESSAGE_DELTA assert "chunk" in result.data assert result.data["chunk"]["role"] == "assistant" @@ -301,20 +336,36 @@ def test_message_delta_validate(self): def test_message_completed_validate(self): """Test message.completed result.""" message = Message(role="assistant", content="Complete response") - result = AgentRunResult.message_completed(message) + result = AgentRunResult.message_completed("run_1", message) + assert result.run_id == "run_1" assert result.type == AgentRunResultType.MESSAGE_COMPLETED assert "message" in result.data assert result.data["message"]["role"] == "assistant" + def test_artifact_created_validate(self): + """Test artifact.created result.""" + result = AgentRunResult.artifact_created( + run_id="run_1", + artifact_id="artifact_1", + artifact_type="image", + mime_type="image/png", + ) + + assert result.run_id == "run_1" + assert result.type == AgentRunResultType.ARTIFACT_CREATED + assert result.data["artifact_id"] == "artifact_1" + def test_tool_call_started_validate(self): """Test tool.call.started result.""" result = AgentRunResult.tool_call_started( + run_id="run_1", tool_call_id="call_1", tool_name="weather", parameters={"city": "Tokyo"}, ) + assert result.run_id == "run_1" assert result.type == AgentRunResultType.TOOL_CALL_STARTED assert result.data["tool_call_id"] == "call_1" assert result.data["tool_name"] == "weather" @@ -323,34 +374,40 @@ def test_tool_call_started_validate(self): def test_tool_call_completed_validate(self): """Test tool.call.completed result.""" result = AgentRunResult.tool_call_completed( + run_id="run_1", tool_call_id="call_1", tool_name="weather", result={"temp": 25, "condition": "sunny"}, error=None, ) + assert result.run_id == "run_1" assert result.type == AgentRunResultType.TOOL_CALL_COMPLETED assert result.data["result"]["temp"] == 25 def test_tool_call_completed_with_error(self): """Test tool.call.completed with error.""" result = AgentRunResult.tool_call_completed( + run_id="run_1", tool_call_id="call_2", tool_name="weather", result=None, error="API timeout", ) + assert result.run_id == "run_1" assert result.type == AgentRunResultType.TOOL_CALL_COMPLETED assert result.data["error"] == "API timeout" def test_state_updated_backward_compatible(self): """Test state.updated backward compatible (default scope=conversation).""" result = AgentRunResult.state_updated( + run_id="run_1", key="external.conversation_id", value="abc123", ) + assert result.run_id == "run_1" assert result.type == AgentRunResultType.STATE_UPDATED assert result.data["scope"] == "conversation" assert result.data["key"] == "external.conversation_id" @@ -359,11 +416,13 @@ def test_state_updated_backward_compatible(self): def test_state_updated_with_scope(self): """Test state.updated with explicit scope.""" result = AgentRunResult.state_updated( + run_id="run_1", key="preferred_language", value="en", scope="actor", ) + assert result.run_id == "run_1" assert result.type == AgentRunResultType.STATE_UPDATED assert result.data["scope"] == "actor" assert result.data["key"] == "preferred_language" @@ -373,6 +432,7 @@ def test_state_updated_all_scopes(self): """Test state.updated with all valid scopes.""" for scope in VALID_STATE_SCOPES: result = AgentRunResult.state_updated( + run_id="run_1", key="test_key", value="test_value", scope=scope, @@ -383,6 +443,7 @@ def test_state_updated_invalid_scope_raises(self): """Test state.updated with invalid scope raises ValueError.""" with pytest.raises(ValueError, match="Invalid scope"): AgentRunResult.state_updated( + run_id="run_1", key="test_key", value="test_value", scope="invalid_scope", @@ -391,16 +452,18 @@ def test_state_updated_invalid_scope_raises(self): def test_run_completed_validate(self): """Test run.completed result.""" message = Message(role="assistant", content="Done") - result = AgentRunResult.run_completed(message=message, finish_reason="stop") + result = AgentRunResult.run_completed(run_id="run_1", message=message, finish_reason="stop") + assert result.run_id == "run_1" assert result.type == AgentRunResultType.RUN_COMPLETED assert result.data["finish_reason"] == "stop" assert result.data["message"]["role"] == "assistant" def test_run_completed_without_message(self): """Test run.completed without message (when message.completed already sent).""" - result = AgentRunResult.run_completed(finish_reason="stop") + result = AgentRunResult.run_completed(run_id="run_1", finish_reason="stop") + assert result.run_id == "run_1" assert result.type == AgentRunResultType.RUN_COMPLETED assert result.data["finish_reason"] == "stop" assert "message" not in result.data @@ -408,11 +471,13 @@ def test_run_completed_without_message(self): def test_run_failed_validate(self): """Test run.failed result.""" result = AgentRunResult.run_failed( + run_id="run_1", error="Upstream timeout", code="upstream.timeout", retryable=True, ) + assert result.run_id == "run_1" assert result.type == AgentRunResultType.RUN_FAILED assert result.data["error"] == "Upstream timeout" assert result.data["code"] == "upstream.timeout" @@ -420,24 +485,27 @@ def test_run_failed_validate(self): def test_run_failed_default_code(self): """Test run.failed with default code.""" - result = AgentRunResult.run_failed(error="Something went wrong") + result = AgentRunResult.run_failed(run_id="run_1", error="Something went wrong") assert result.data["code"] == "runner.error" def test_action_requested_validate(self): """Test action.requested result.""" result = AgentRunResult.action_requested( + run_id="run_1", action="platform.message.edit", - parameters={"message_id": "msg_1", "new_text": "Updated"}, + target={"message_id": "msg_1"}, + payload={"new_text": "Updated"}, ) + assert result.run_id == "run_1" assert result.type == AgentRunResultType.ACTION_REQUESTED assert result.data["action"] == "platform.message.edit" def test_result_model_dump_json(self): """Test model_dump(mode='json') for serialization.""" message = Message(role="assistant", content="Test") - result = AgentRunResult.message_completed(message) + result = AgentRunResult.message_completed("run_1", message) dumped = result.model_dump(mode="json") assert dumped["type"] == "message.completed" @@ -478,16 +546,18 @@ class TestCapabilitiesAndPermissions: """Test AgentRunnerCapabilities and AgentRunnerPermissions.""" def test_capabilities_defaults(self): - """Test all capabilities default to False.""" + """Test capabilities defaults.""" caps = AgentRunnerCapabilities() assert not caps.streaming assert not caps.tool_calling assert not caps.knowledge_retrieval assert not caps.multimodal_input - assert not caps.event_context + # Protocol v1 defaults + assert caps.event_context is True # Default True for Protocol v1 assert not caps.platform_api assert not caps.interrupt assert not caps.stateful_session + assert caps.self_managed_context is True # Default True for Protocol v1 def test_permissions_defaults(self): """Test all permissions default to empty lists.""" @@ -495,6 +565,9 @@ def test_permissions_defaults(self): assert perms.models == [] assert perms.tools == [] assert perms.knowledge_bases == [] + assert perms.history == [] # New field + assert perms.events == [] # New field + assert perms.artifacts == [] # New field assert perms.storage == [] assert perms.files == [] assert perms.platform_api == [] @@ -513,10 +586,118 @@ def test_capabilities_from_dict(self): def test_permissions_from_dict(self): """Test permissions from manifest data.""" perms = AgentRunnerPermissions( - models=["list", "invoke", "stream"], - tools=["list", "detail", "call"], - storage=["plugin", "workspace"], + models=["invoke", "stream", "rerank"], + tools=["detail", "call"], + storage=["plugin", "workspace", "binding"], + ) + assert perms.models == ["invoke", "stream", "rerank"] + assert perms.tools == ["detail", "call"] + assert perms.storage == ["plugin", "workspace", "binding"] + + +class TestContextPolicy: + """Test AgentRunnerContextPolicy.""" + + def test_context_policy_defaults(self): + """Test context policy defaults for Protocol v1.""" + policy = AgentRunnerContextPolicy() + + assert policy.ownership == "self_managed" + assert policy.bootstrap == "current_event" + assert policy.max_inline_events == 0 + assert policy.supports_history_pull is True + assert policy.owns_compaction is True + + def test_context_policy_host_bootstrap(self): + """Test context policy for host_bootstrap mode.""" + policy = AgentRunnerContextPolicy( + ownership="host_bootstrap", + bootstrap="recent_tail", + max_inline_events=10, + ) + + assert policy.ownership == "host_bootstrap" + assert policy.bootstrap == "recent_tail" + assert policy.max_inline_events == 10 + + +class TestAgentRunnerManifest: + """Test AgentRunnerManifest.""" + + def test_manifest_minimal(self): + """Test minimal manifest.""" + manifest = AgentRunnerManifest( + id="plugin:author/plugin/runner", + name="default", + label={"en_US": "Default Runner"}, + ) + + assert manifest.id == "plugin:author/plugin/runner" + assert manifest.name == "default" + assert manifest.capabilities is not None + assert manifest.permissions is not None + assert manifest.context is not None + + def test_manifest_full(self): + """Test full manifest.""" + manifest = AgentRunnerManifest( + id="plugin:author/plugin/runner", + name="default", + label={"en_US": "Runner"}, + description={"en_US": "A runner"}, + capabilities=AgentRunnerCapabilities(streaming=True), + permissions=AgentRunnerPermissions(models=["invoke", "stream"]), + context=AgentRunnerContextPolicy(ownership="host_bootstrap"), ) - assert perms.models == ["list", "invoke", "stream"] - assert perms.tools == ["list", "detail", "call"] - assert perms.storage == ["plugin", "workspace"] + + assert manifest.capabilities.streaming is True + assert manifest.permissions.models == ["invoke", "stream"] + assert manifest.context.ownership == "host_bootstrap" + + +class TestEventContextProtocolV1: + """Test event context for Protocol v1.""" + + def test_event_context_required_fields(self): + """Test event context required fields.""" + event = AgentEventContext( + event_id="evt_1", + event_type="message.received", + source="platform", + ) + + assert event.event_id == "evt_1" + assert event.event_type == "message.received" + assert event.source == "platform" + + def test_event_context_missing_required(self): + """Test event context with missing required fields.""" + with pytest.raises(pydantic.ValidationError): + AgentEventContext( + # Missing event_id, event_type, source + ) + + +class TestDeliveryContext: + """Test DeliveryContext.""" + + def test_delivery_context_required(self): + """Test delivery context required field.""" + delivery = DeliveryContext(surface="platform") + + assert delivery.surface == "platform" + assert delivery.supports_streaming is False + assert delivery.reply_target is None + + def test_delivery_context_full(self): + """Test full delivery context.""" + delivery = DeliveryContext( + surface="webui", + supports_streaming=True, + supports_edit=True, + max_message_size=4096, + ) + + assert delivery.surface == "webui" + assert delivery.supports_streaming is True + assert delivery.max_message_size == 4096 diff --git a/tests/api/entities/builtin/agent_runner/test_history_event_entities.py b/tests/api/entities/builtin/agent_runner/test_history_event_entities.py new file mode 100644 index 00000000..3c84c9c4 --- /dev/null +++ b/tests/api/entities/builtin/agent_runner/test_history_event_entities.py @@ -0,0 +1,234 @@ +"""Tests for TranscriptItem, HistoryPage, EventPage, AgentEventRecord entities.""" +from __future__ import annotations + +import pytest + +from langbot_plugin.api.entities.builtin.agent_runner.transcript import TranscriptItem +from langbot_plugin.api.entities.builtin.agent_runner.page_results import ( + HistoryPage, + HistorySearchResult, + AgentEventRecord, + EventPage, +) + + +class TestTranscriptItem: + """Test TranscriptItem serialization.""" + + def test_transcript_item_basic(self): + """Test basic TranscriptItem creation.""" + item = TranscriptItem( + transcript_id="t1", + event_id="e1", + conversation_id="c1", + role="user", + content="Hello", + ) + + assert item.transcript_id == "t1" + assert item.event_id == "e1" + assert item.conversation_id == "c1" + assert item.role == "user" + assert item.content == "Hello" + assert item.item_type == "message" # default + assert item.artifact_refs == [] + assert item.metadata == {} + + def test_transcript_item_serialization(self): + """Test TranscriptItem model_dump.""" + item = TranscriptItem( + transcript_id="t1", + event_id="e1", + conversation_id="c1", + role="assistant", + content="Hi there", + artifact_refs=[{"artifact_id": "a1", "artifact_type": "image"}], + seq=1, + cursor="1", + metadata={"sender_id": "user1"}, + ) + + data = item.model_dump(mode="json") + assert data["transcript_id"] == "t1" + assert data["artifact_refs"] == [{"artifact_id": "a1", "artifact_type": "image"}] + assert data["seq"] == 1 + + def test_transcript_item_with_content_json(self): + """Test TranscriptItem with structured content.""" + item = TranscriptItem( + transcript_id="t2", + event_id="e2", + conversation_id="c1", + role="assistant", + content_json={"role": "assistant", "content": "Response"}, + ) + + assert item.content_json == {"role": "assistant", "content": "Response"} + + +class TestHistoryPage: + """Test HistoryPage serialization.""" + + def test_history_page_empty(self): + """Test empty HistoryPage.""" + page = HistoryPage() + + assert page.items == [] + assert page.next_cursor is None + assert page.prev_cursor is None + assert page.has_more is False + + def test_history_page_with_items(self): + """Test HistoryPage with items.""" + items = [ + TranscriptItem( + transcript_id=f"t{i}", + event_id=f"e{i}", + conversation_id="c1", + role="user" if i % 2 == 0 else "assistant", + content=f"Message {i}", + ) + for i in range(3) + ] + + page = HistoryPage( + items=items, + next_cursor="10", + prev_cursor="1", + has_more=True, + total_count=100, + ) + + assert len(page.items) == 3 + assert page.next_cursor == "10" + assert page.has_more is True + assert page.total_count == 100 + + def test_history_page_serialization(self): + """Test HistoryPage model_dump.""" + page = HistoryPage( + items=[ + TranscriptItem( + transcript_id="t1", + event_id="e1", + conversation_id="c1", + role="user", + content="Hi", + ) + ], + has_more=False, + ) + + data = page.model_dump(mode="json") + assert "items" in data + assert data["items"][0]["transcript_id"] == "t1" + + +class TestHistorySearchResult: + """Test HistorySearchResult serialization.""" + + def test_history_search_result_basic(self): + """Test basic HistorySearchResult.""" + result = HistorySearchResult( + query="test query", + items=[ + TranscriptItem( + transcript_id="t1", + event_id="e1", + conversation_id="c1", + role="user", + content="test query result", + ) + ], + ) + + assert result.query == "test query" + assert len(result.items) == 1 + + +class TestAgentEventRecord: + """Test AgentEventRecord serialization.""" + + def test_event_record_basic(self): + """Test basic AgentEventRecord.""" + record = AgentEventRecord( + event_id="e1", + event_type="message.received", + source="platform", + ) + + assert record.event_id == "e1" + assert record.event_type == "message.received" + assert record.source == "platform" + + def test_event_record_full(self): + """Test AgentEventRecord with all fields.""" + record = AgentEventRecord( + event_id="e1", + event_type="message.received", + event_time=1700000000, + source="platform", + bot_id="bot1", + workspace_id="ws1", + conversation_id="c1", + thread_id="t1", + actor_type="user", + actor_id="user1", + actor_name="Alice", + subject_type="message", + subject_id="m1", + input_summary="Hello", + seq=1, + cursor="1", + metadata={"platform": "telegram"}, + ) + + assert record.bot_id == "bot1" + assert record.actor_name == "Alice" + assert record.input_summary == "Hello" + + def test_event_record_serialization(self): + """Test AgentEventRecord model_dump.""" + record = AgentEventRecord( + event_id="e1", + event_type="tool.call.started", + source="runner", + actor_type="runner", + metadata={"tool_name": "search"}, + ) + + data = record.model_dump(mode="json") + assert data["event_type"] == "tool.call.started" + assert data["metadata"]["tool_name"] == "search" + + +class TestEventPage: + """Test EventPage serialization.""" + + def test_event_page_empty(self): + """Test empty EventPage.""" + page = EventPage() + + assert page.items == [] + assert page.next_cursor is None + assert page.has_more is False + + def test_event_page_with_items(self): + """Test EventPage with items.""" + items = [ + AgentEventRecord( + event_id=f"e{i}", + event_type="message.received", + source="platform", + ) + for i in range(3) + ] + + page = EventPage( + items=items, + next_cursor="10", + has_more=True, + ) + + assert len(page.items) == 3 + assert page.has_more is True diff --git a/tests/api/proxies/test_agent_run_api_proxy.py b/tests/api/proxies/test_agent_run_api_proxy.py index 6fb85dec..307e45d6 100644 --- a/tests/api/proxies/test_agent_run_api_proxy.py +++ b/tests/api/proxies/test_agent_run_api_proxy.py @@ -27,6 +27,8 @@ from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.event import AgentEventContext +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext class MockHandler: @@ -59,7 +61,13 @@ def create_mock_context( return AgentRunContext( run_id=run_id, trigger=AgentTrigger(type='user_message'), + event=AgentEventContext( + event_id='test_event', + event_type='message.received', + source='test', + ), input=AgentInput(content='test input'), + delivery=DeliveryContext(surface='test'), runtime=AgentRuntimeContext(query_id=query_id, deadline_at=deadline_at), resources=AgentResources( models=[ModelResource.model_validate(m) for m in (models or [])], diff --git a/tests/api/proxies/test_history_event_api_proxy.py b/tests/api/proxies/test_history_event_api_proxy.py new file mode 100644 index 00000000..47c87570 --- /dev/null +++ b/tests/api/proxies/test_history_event_api_proxy.py @@ -0,0 +1,224 @@ +"""Tests for AgentRunAPIProxy history and event methods.""" +from __future__ import annotations + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from langbot_plugin.api.proxies.agent_run_api import AgentRunAPIProxy, PermissionDeniedError +from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext +from langbot_plugin.api.entities.builtin.agent_runner.resources import ( + AgentResources, + ModelResource, + ToolResource, + KnowledgeBaseResource, + StorageResource, +) +from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext +from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.event import AgentEventContext +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + + +def create_mock_context( + run_id: str = 'test_run', + conversation_id: str | None = None, +) -> AgentRunContext: + """Create a mock AgentRunContext for testing.""" + return AgentRunContext( + run_id=run_id, + trigger=AgentTrigger(type='message.received'), + event=AgentEventContext( + event_id='test_event', + event_type='message.received', + source='test', + data={'conversation_id': conversation_id} if conversation_id else {}, + ), + input=AgentInput(text='test input'), + delivery=DeliveryContext(surface='test'), + runtime=AgentRuntimeContext(query_id=1, deadline_at=None), + resources=AgentResources( + models=[ModelResource(model_id='model_001')], + tools=[ToolResource(tool_name='tool_001')], + knowledge_bases=[KnowledgeBaseResource(kb_id='kb_001')], + storage=StorageResource(plugin_storage=True, workspace_storage=False), + ), + ) + + +class TestHistoryPageMethod: + """Test history_page method.""" + + @pytest.mark.anyio + async def test_history_page_sends_run_id(self): + """Test history_page sends run_id in request.""" + mock_handler = MagicMock() + mock_handler.call_action = AsyncMock(return_value={ + 'items': [], + 'next_cursor': None, + 'prev_cursor': None, + 'has_more': False, + }) + + ctx = create_mock_context(run_id='run_123') + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + result = await proxy.history_page() + + mock_handler.call_action.assert_called_once() + call_args = mock_handler.call_action.call_args + assert call_args[0][0] == PluginToRuntimeAction.HISTORY_PAGE + assert call_args[0][1]['run_id'] == 'run_123' + + @pytest.mark.anyio + async def test_history_page_with_parameters(self): + """Test history_page passes all parameters.""" + mock_handler = MagicMock() + mock_handler.call_action = AsyncMock(return_value={ + 'items': [], + 'next_cursor': None, + 'prev_cursor': None, + 'has_more': False, + }) + + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.history_page( + conversation_id='conv_1', + before_cursor='10', + limit=20, + direction='forward', + include_artifacts=True, + ) + + call_args = mock_handler.call_action.call_args + data = call_args[0][1] + assert data['conversation_id'] == 'conv_1' + assert data['before_cursor'] == '10' + assert data['limit'] == 20 + assert data['direction'] == 'forward' + assert data['include_artifacts'] is True + + +class TestHistorySearchMethod: + """Test history_search method.""" + + @pytest.mark.anyio + async def test_history_search_sends_run_id(self): + """Test history_search sends run_id in request.""" + mock_handler = MagicMock() + mock_handler.call_action = AsyncMock(return_value={ + 'items': [], + 'total_count': 0, + 'query': 'test', + }) + + ctx = create_mock_context(run_id='run_456') + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + result = await proxy.history_search(query='test query') + + call_args = mock_handler.call_action.call_args + assert call_args[0][0] == PluginToRuntimeAction.HISTORY_SEARCH + assert call_args[0][1]['run_id'] == 'run_456' + assert call_args[0][1]['query'] == 'test query' + + @pytest.mark.anyio + async def test_history_search_with_filters(self): + """Test history_search passes filters.""" + mock_handler = MagicMock() + mock_handler.call_action = AsyncMock(return_value={ + 'items': [], + 'total_count': 0, + 'query': 'test', + }) + + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.history_search( + query='search term', + filters={'roles': ['user']}, + top_k=5, + ) + + call_args = mock_handler.call_action.call_args + data = call_args[0][1] + assert data['filters'] == {'roles': ['user']} + assert data['top_k'] == 5 + + +class TestEventGetMethod: + """Test event_get method.""" + + @pytest.mark.anyio + async def test_event_get_sends_run_id(self): + """Test event_get sends run_id in request.""" + mock_handler = MagicMock() + mock_handler.call_action = AsyncMock(return_value={ + 'event_id': 'event_1', + 'event_type': 'message.received', + 'source': 'platform', + }) + + ctx = create_mock_context(run_id='run_789') + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + result = await proxy.event_get(event_id='event_1') + + call_args = mock_handler.call_action.call_args + assert call_args[0][0] == PluginToRuntimeAction.EVENT_GET + assert call_args[0][1]['run_id'] == 'run_789' + assert call_args[0][1]['event_id'] == 'event_1' + + +class TestEventPageMethod: + """Test event_page method.""" + + @pytest.mark.anyio + async def test_event_page_sends_run_id(self): + """Test event_page sends run_id in request.""" + mock_handler = MagicMock() + mock_handler.call_action = AsyncMock(return_value={ + 'items': [], + 'next_cursor': None, + 'has_more': False, + }) + + ctx = create_mock_context(run_id='run_abc') + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + result = await proxy.event_page() + + call_args = mock_handler.call_action.call_args + assert call_args[0][0] == PluginToRuntimeAction.EVENT_PAGE + assert call_args[0][1]['run_id'] == 'run_abc' + + @pytest.mark.anyio + async def test_event_page_with_parameters(self): + """Test event_page passes all parameters.""" + mock_handler = MagicMock() + mock_handler.call_action = AsyncMock(return_value={ + 'items': [], + 'next_cursor': None, + 'has_more': False, + }) + + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.event_page( + conversation_id='conv_1', + event_types=['message.received', 'message.completed'], + before_cursor='50', + limit=30, + ) + + call_args = mock_handler.call_action.call_args + data = call_args[0][1] + assert data['conversation_id'] == 'conv_1' + assert data['event_types'] == ['message.received', 'message.completed'] + assert data['before_cursor'] == '50' + assert data['limit'] == 30 diff --git a/tests/runtime/plugin/test_mgr_agent_runner.py b/tests/runtime/plugin/test_mgr_agent_runner.py index 55c43170..43c18547 100644 --- a/tests/runtime/plugin/test_mgr_agent_runner.py +++ b/tests/runtime/plugin/test_mgr_agent_runner.py @@ -14,6 +14,8 @@ from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext +from langbot_plugin.api.entities.builtin.agent_runner.event import AgentEventContext +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner @@ -26,9 +28,10 @@ async def run( ) -> typing.AsyncGenerator[AgentRunResult, None]: """Mock run that yields results.""" yield AgentRunResult.message_completed( - Message(role="assistant", content=f"Echo: {ctx.input.to_text()}") + run_id=ctx.run_id, + message=Message(role="assistant", content=f"Echo: {ctx.input.to_text()}"), ) - yield AgentRunResult.run_completed(finish_reason="stop") + yield AgentRunResult.run_completed(run_id=ctx.run_id, finish_reason="stop") class StreamingAgentRunner(AgentRunner): @@ -40,8 +43,8 @@ async def run( """Mock run that streams chunks using MessageChunk.""" for word in ["Hello", " ", "world"]: chunk = MessageChunk(role="assistant", content=word) - yield AgentRunResult.message_delta(chunk) - yield AgentRunResult.run_completed(finish_reason="stop") + yield AgentRunResult.message_delta(run_id=ctx.run_id, delta=chunk) + yield AgentRunResult.run_completed(run_id=ctx.run_id, finish_reason="stop") class FailingAgentRunner(AgentRunner): @@ -53,7 +56,7 @@ async def run( """Mock run that raises an exception after yielding.""" # Must have at least one yield to be an async generator chunk = MessageChunk(role="assistant", content="Starting...") - yield AgentRunResult.message_delta(chunk) + yield AgentRunResult.message_delta(run_id=ctx.run_id, delta=chunk) raise RuntimeError("Intentional test failure") @@ -66,7 +69,7 @@ async def run( import asyncio await asyncio.sleep(0.05) - yield AgentRunResult.run_completed(finish_reason="stop") + yield AgentRunResult.run_completed(run_id=ctx.run_id, finish_reason="stop") def create_mock_component_manifest( @@ -169,7 +172,13 @@ def create_run_context() -> AgentRunContext: return AgentRunContext( run_id="test_run", trigger=AgentTrigger(type="message.received"), + event=AgentEventContext( + event_id="test_event", + event_type="message.received", + source="test", + ), input=AgentInput(text="Hello"), + delivery=DeliveryContext(surface="test"), resources=AgentResources(), runtime=AgentRuntimeContext(), ) From 971c804c9beab97426f4ddfe92d6d2e67843c2dd Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Sat, 23 May 2026 17:29:18 +0800 Subject: [PATCH 13/16] feat(agent-runner): add artifact pull API contract --- .../entities/builtin/agent_runner/__init__.py | 7 + .../entities/builtin/agent_runner/artifact.py | 92 +++++++ .../api/proxies/agent_run_api.py | 94 +++++++ .../entities/io/actions/enums.py | 4 + .../agent_runner/test_artifact_entities.py | 192 +++++++++++++++ tests/api/proxies/test_artifact_api_proxy.py | 231 ++++++++++++++++++ 6 files changed, 620 insertions(+) create mode 100644 src/langbot_plugin/api/entities/builtin/agent_runner/artifact.py create mode 100644 tests/api/entities/builtin/agent_runner/test_artifact_entities.py create mode 100644 tests/api/proxies/test_artifact_api_proxy.py diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py b/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py index db168c07..9d52eef2 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py @@ -65,6 +65,10 @@ AgentEventRecord, EventPage, ) +from langbot_plugin.api.entities.builtin.agent_runner.artifact import ( + ArtifactMetadata, + ArtifactReadResult, +) __all__ = [ # Manifest and policy @@ -112,4 +116,7 @@ "HistorySearchResult", "AgentEventRecord", "EventPage", + # Artifact APIs + "ArtifactMetadata", + "ArtifactReadResult", ] \ No newline at end of file diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/artifact.py b/src/langbot_plugin/api/entities/builtin/agent_runner/artifact.py new file mode 100644 index 00000000..34e449d8 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/artifact.py @@ -0,0 +1,92 @@ +"""Artifact entities for Host-owned artifact store.""" +from __future__ import annotations + +import typing +import pydantic + + +class ArtifactMetadata(pydantic.BaseModel): + """Metadata for an artifact in the Host store. + + Artifacts are large files, images, tool results, or platform attachments + that should not be inlined into AgentRunContext. They are stored by Host + and accessed via pull APIs by authorized runners. + """ + + artifact_id: str + """Unique artifact identifier.""" + + artifact_type: str + """Artifact type: 'image', 'file', 'voice', 'tool_result', 'platform_attachment', etc.""" + + mime_type: str | None = None + """MIME type of the content.""" + + name: str | None = None + """Original file name (if applicable).""" + + size_bytes: int | None = None + """Size in bytes.""" + + sha256: str | None = None + """SHA256 hash of content (for integrity verification).""" + + source: str + """Source of artifact: 'platform', 'runner', 'tool', 'system'.""" + + conversation_id: str | None = None + """Conversation this artifact belongs to.""" + + run_id: str | None = None + """Run ID that created this artifact.""" + + runner_id: str | None = None + """Runner ID that created this artifact.""" + + created_at: int | None = None + """Unix timestamp when artifact was created.""" + + expires_at: int | None = None + """Unix timestamp when artifact expires (optional).""" + + metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Additional metadata (platform-specific info, etc.).""" + + model_config = pydantic.ConfigDict(extra='forbid') + + +class ArtifactReadResult(pydantic.BaseModel): + """Result of reading artifact content. + + Supports two modes: + 1. Inline bytes (small artifacts): returns content_base64 + 2. File key reference (large artifacts): returns file_key for chunked transfer + + Host may enforce max read size limits to prevent memory exhaustion. + """ + + artifact_id: str + """Artifact identifier.""" + + mime_type: str | None = None + """MIME type of the content.""" + + size_bytes: int | None = None + """Total size of artifact in bytes.""" + + offset: int = 0 + """Offset of this read (for range reads).""" + + length: int | None = None + """Length of data read (None if using file_key).""" + + content_base64: str | None = None + """Base64-encoded content (for small artifacts or range reads).""" + + file_key: str | None = None + """File key for chunked transfer (for large artifacts).""" + + has_more: bool = False + """Whether more data is available (for range reads).""" + + model_config = pydantic.ConfigDict(extra='forbid') diff --git a/src/langbot_plugin/api/proxies/agent_run_api.py b/src/langbot_plugin/api/proxies/agent_run_api.py index d7002dec..87933484 100644 --- a/src/langbot_plugin/api/proxies/agent_run_api.py +++ b/src/langbot_plugin/api/proxies/agent_run_api.py @@ -643,3 +643,97 @@ async def event_page( timeout, ) return resp + + # ================= Artifact APIs (run-scoped) ================= + + async def artifact_metadata(self, artifact_id: str) -> dict[str, Any]: + """Get metadata for an artifact. + + Args: + artifact_id: The artifact ID to retrieve metadata for. + + Returns: + ArtifactMetadata as dict with artifact_id, artifact_type, mime_type, + size_bytes, source, conversation_id, run_id, etc. + + Raises: + PermissionDeniedError: If artifact not accessible by current run. + """ + timeout = self._bounded_timeout(default=15.0) + resp = await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.ARTIFACT_METADATA, + { + "run_id": self.run_id, + "artifact_id": artifact_id, + }, + timeout, + ) + return resp + + async def artifact_read( + self, + artifact_id: str, + offset: int = 0, + limit: int | None = None, + ) -> dict[str, Any]: + """Read artifact content. + + For small artifacts, returns content_base64 directly. + For large artifacts, may return file_key for chunked transfer. + + Args: + artifact_id: The artifact ID to read. + offset: Byte offset to start reading from (for range reads). + limit: Maximum bytes to read. Host may enforce a hard limit. + + Returns: + ArtifactReadResult as dict with: + - artifact_id: The artifact identifier + - mime_type: MIME type of content + - size_bytes: Total artifact size + - offset: Offset of this read + - length: Length of data read (or None for file_key mode) + - content_base64: Base64-encoded content (for inline mode) + - file_key: File key for chunked transfer (for large artifacts) + - has_more: Whether more data is available + + Raises: + PermissionDeniedError: If artifact not accessible by current run. + + Note: + Host may enforce max read size limits to prevent memory exhaustion. + For large artifacts, prefer using file_key and chunked transfer. + """ + timeout = self._bounded_timeout(default=60.0) + resp = await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.ARTIFACT_READ, + { + "run_id": self.run_id, + "artifact_id": artifact_id, + "offset": offset, + "limit": limit, + }, + timeout, + ) + return resp + + # Alias for artifact_read with range semantics + async def artifact_read_range( + self, + artifact_id: str, + offset: int = 0, + length: int | None = None, + ) -> dict[str, Any]: + """Read a range of artifact content. + + Alias for artifact_read with clearer range semantics. + + Args: + artifact_id: The artifact ID to read. + offset: Byte offset to start reading from. + length: Maximum bytes to read. + + Returns: + ArtifactReadResult as dict. + """ + return await self.artifact_read(artifact_id, offset=offset, limit=length) diff --git a/src/langbot_plugin/entities/io/actions/enums.py b/src/langbot_plugin/entities/io/actions/enums.py index 0b9eb58a..60d36f9b 100644 --- a/src/langbot_plugin/entities/io/actions/enums.py +++ b/src/langbot_plugin/entities/io/actions/enums.py @@ -85,6 +85,10 @@ class PluginToRuntimeAction(ActionType): EVENT_GET = "event_get" EVENT_PAGE = "event_page" + """Agent Artifact APIs (run-scoped, requires run_id authorization)""" + ARTIFACT_METADATA = "artifact_metadata" + ARTIFACT_READ = "artifact_read" + class RuntimeToPluginAction(ActionType): """The action from runtime to plugin.""" diff --git a/tests/api/entities/builtin/agent_runner/test_artifact_entities.py b/tests/api/entities/builtin/agent_runner/test_artifact_entities.py new file mode 100644 index 00000000..73c02c2e --- /dev/null +++ b/tests/api/entities/builtin/agent_runner/test_artifact_entities.py @@ -0,0 +1,192 @@ +"""Tests for artifact entities and proxy methods.""" +from __future__ import annotations + +import pytest +import pydantic + +from langbot_plugin.api.entities.builtin.agent_runner.artifact import ( + ArtifactMetadata, + ArtifactReadResult, +) + + +class TestArtifactMetadata: + """Test ArtifactMetadata entity.""" + + def test_artifact_metadata_required_fields(self): + """Test that required fields are enforced.""" + with pytest.raises(pydantic.ValidationError): + ArtifactMetadata() + + def test_artifact_metadata_minimal(self): + """Test minimal valid metadata.""" + metadata = ArtifactMetadata( + artifact_id="art_001", + artifact_type="image", + source="platform", + ) + assert metadata.artifact_id == "art_001" + assert metadata.artifact_type == "image" + assert metadata.source == "platform" + assert metadata.mime_type is None + assert metadata.conversation_id is None + assert metadata.run_id is None + + def test_artifact_metadata_full(self): + """Test full metadata with all fields.""" + metadata = ArtifactMetadata( + artifact_id="art_001", + artifact_type="file", + mime_type="application/pdf", + name="document.pdf", + size_bytes=1024, + sha256="abc123", + source="runner", + conversation_id="conv_001", + run_id="run_001", + runner_id="plugin:test/plugin/runner", + created_at=1700000000, + expires_at=1700086400, + metadata={"page_count": 10}, + ) + assert metadata.artifact_id == "art_001" + assert metadata.mime_type == "application/pdf" + assert metadata.size_bytes == 1024 + assert metadata.metadata == {"page_count": 10} + + def test_artifact_metadata_serialization(self): + """Test serialization to JSON.""" + metadata = ArtifactMetadata( + artifact_id="art_001", + artifact_type="image", + source="platform", + metadata={"key": "value"}, + ) + data = metadata.model_dump() + assert data["artifact_id"] == "art_001" + assert data["metadata"] == {"key": "value"} + + def test_artifact_metadata_extra_forbidden(self): + """Test that extra fields are forbidden.""" + with pytest.raises(pydantic.ValidationError): + ArtifactMetadata( + artifact_id="art_001", + artifact_type="image", + source="platform", + unknown_field="should fail", + ) + + def test_artifact_metadata_host_round_trip(self): + """Test that metadata from Host can be parsed without error. + + This verifies that Host returns only fields defined in SDK ArtifactMetadata, + not Host-only fields like bot_id, workspace_id, storage_key, storage_type. + """ + # Simulate what Host returns (after _row_to_dict fix) + host_response = { + "artifact_id": "art_001", + "artifact_type": "file", + "mime_type": "application/pdf", + "name": "document.pdf", + "size_bytes": 1024, + "sha256": "abc123", + "source": "runner", + "conversation_id": "conv_001", + "run_id": "run_001", + "runner_id": "plugin:test/plugin/runner", + "created_at": 1700000000, + "expires_at": 1700086400, + "metadata": {"page_count": 10}, + } + + # Should not raise ValidationError + metadata = ArtifactMetadata.model_validate(host_response) + assert metadata.artifact_id == "art_001" + assert metadata.name == "document.pdf" + + def test_artifact_metadata_rejects_host_only_fields(self): + """Test that Host-only fields cause validation error. + + This ensures we don't accidentally leak Host-only fields. + """ + # If Host returns bot_id or workspace_id, parsing should fail + host_response_with_extras = { + "artifact_id": "art_001", + "artifact_type": "file", + "source": "runner", + "bot_id": "bot_001", # Host-only field + } + + with pytest.raises(pydantic.ValidationError): + ArtifactMetadata.model_validate(host_response_with_extras) + + +class TestArtifactReadResult: + """Test ArtifactReadResult entity.""" + + def test_artifact_read_result_minimal(self): + """Test minimal read result.""" + result = ArtifactReadResult( + artifact_id="art_001", + ) + assert result.artifact_id == "art_001" + assert result.content_base64 is None + assert result.file_key is None + assert result.offset == 0 + + def test_artifact_read_result_inline(self): + """Test read result with inline content.""" + import base64 + + content = b"test content" + result = ArtifactReadResult( + artifact_id="art_001", + mime_type="text/plain", + size_bytes=len(content), + offset=0, + length=len(content), + content_base64=base64.b64encode(content).decode("utf-8"), + has_more=False, + ) + assert result.content_base64 is not None + assert base64.b64decode(result.content_base64) == content + assert result.has_more is False + + def test_artifact_read_result_file_key(self): + """Test read result with file key for chunked transfer.""" + result = ArtifactReadResult( + artifact_id="art_001", + mime_type="video/mp4", + size_bytes=10_000_000, + offset=0, + length=None, + file_key="temp_file_001", + has_more=True, + ) + assert result.file_key == "temp_file_001" + assert result.content_base64 is None + assert result.has_more is True + + def test_artifact_read_result_serialization(self): + """Test serialization to JSON.""" + result = ArtifactReadResult( + artifact_id="art_001", + mime_type="image/png", + size_bytes=1024, + offset=0, + length=1024, + content_base64="base64data", + has_more=False, + ) + data = result.model_dump() + assert data["artifact_id"] == "art_001" + assert data["mime_type"] == "image/png" + assert data["content_base64"] == "base64data" + + def test_artifact_read_result_extra_forbidden(self): + """Test that extra fields are forbidden.""" + with pytest.raises(pydantic.ValidationError): + ArtifactReadResult( + artifact_id="art_001", + unknown_field="should fail", + ) diff --git a/tests/api/proxies/test_artifact_api_proxy.py b/tests/api/proxies/test_artifact_api_proxy.py new file mode 100644 index 00000000..cb08a48b --- /dev/null +++ b/tests/api/proxies/test_artifact_api_proxy.py @@ -0,0 +1,231 @@ +"""Tests for artifact API proxy methods.""" +from __future__ import annotations + +from unittest.mock import MagicMock, AsyncMock, patch + +from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext +from langbot_plugin.api.proxies.agent_run_api import AgentRunAPIProxy +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + + +def make_context() -> AgentRunContext: + """Create a test AgentRunContext.""" + return AgentRunContext( + run_id="run_001", + trigger={"type": "message.received", "source": "platform", "timestamp": None}, + event={ + "event_id": "evt_001", + "event_type": "message.received", + "source": "platform", + }, + input={"text": "Hello"}, + delivery={"surface": "test"}, + resources={"models": [], "tools": [], "knowledge_bases": [], "files": [], "storage": {}}, + runtime={"langbot_version": "1.0", "sdk_protocol_version": "1", "deadline_at": None, "metadata": {}}, + state={}, + config={}, + ) + + +class TestArtifactAPIProxy: + """Test artifact API proxy methods.""" + + def test_exposes_artifact_metadata_method(self): + """AgentRunAPIProxy exposes artifact_metadata method.""" + ctx = make_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert hasattr(proxy, 'artifact_metadata'), \ + "AgentRunAPIProxy should expose artifact_metadata() method" + + def test_exposes_artifact_read_method(self): + """AgentRunAPIProxy exposes artifact_read method.""" + ctx = make_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert hasattr(proxy, 'artifact_read'), \ + "AgentRunAPIProxy should expose artifact_read() method" + + def test_exposes_artifact_read_range_method(self): + """AgentRunAPIProxy exposes artifact_read_range method.""" + ctx = make_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=MagicMock()) + + assert hasattr(proxy, 'artifact_read_range'), \ + "AgentRunAPIProxy should expose artifact_read_range() method" + + +class TestArtifactAPIProxyPayloads: + """Test that artifact API proxy methods send correct action payloads. + + Uses mock to intercept the call_action and verify payloads without + actually running async code. + """ + + def test_artifact_metadata_payload_structure(self): + """artifact_metadata constructs correct action payload.""" + ctx = make_context() + mock_handler = MagicMock() + mock_call_action = AsyncMock(return_value={"ok": True, "data": {}}) + mock_handler.call_action = mock_call_action + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + # Mock the async method to just capture the call + original_method = proxy.artifact_metadata + + # Get the coroutine to inspect what it would call + import inspect + coro = original_method(artifact_id="art_001") + + # The coroutine is created; we can close it without running + # But we need to verify what it WOULD call + # Instead, let's check the method signature and logic directly + + # Verify the method exists and is async + assert inspect.iscoroutinefunction(original_method) + + # Clean up the coroutine + coro.close() + + def test_artifact_metadata_calls_correct_action(self): + """artifact_metadata calls ARTIFACT_METADATA with correct args.""" + import asyncio + + ctx = make_context() + mock_handler = MagicMock() + mock_handler.call_action = AsyncMock(return_value={"ok": True, "data": {}}) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + # Run in a new event loop + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(proxy.artifact_metadata(artifact_id="art_001")) + finally: + loop.close() + + mock_handler.call_action.assert_called_once() + call_args = mock_handler.call_action.call_args + action_name = call_args[0][0] + payload = call_args[0][1] + + assert action_name == PluginToRuntimeAction.ARTIFACT_METADATA + assert payload["run_id"] == "run_001" + assert payload["artifact_id"] == "art_001" + + def test_artifact_read_calls_correct_action(self): + """artifact_read calls ARTIFACT_READ with correct args.""" + import asyncio + + ctx = make_context() + mock_handler = MagicMock() + mock_handler.call_action = AsyncMock(return_value={"ok": True, "data": {}}) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete( + proxy.artifact_read(artifact_id="art_002", offset=100, limit=1024) + ) + finally: + loop.close() + + mock_handler.call_action.assert_called_once() + call_args = mock_handler.call_action.call_args + action_name = call_args[0][0] + payload = call_args[0][1] + + assert action_name == PluginToRuntimeAction.ARTIFACT_READ + assert payload["run_id"] == "run_001" + assert payload["artifact_id"] == "art_002" + assert payload["offset"] == 100 + assert payload["limit"] == 1024 + + def test_artifact_read_default_offset_limit(self): + """artifact_read uses default offset=0 and limit=None.""" + import asyncio + + ctx = make_context() + mock_handler = MagicMock() + mock_handler.call_action = AsyncMock(return_value={"ok": True, "data": {}}) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(proxy.artifact_read(artifact_id="art_003")) + finally: + loop.close() + + call_args = mock_handler.call_action.call_args + payload = call_args[0][1] + + assert payload["offset"] == 0 + assert payload["limit"] is None + + def test_artifact_read_range_calls_correct_action(self): + """artifact_read_range calls ARTIFACT_READ with correct args.""" + import asyncio + + ctx = make_context() + mock_handler = MagicMock() + mock_handler.call_action = AsyncMock(return_value={"ok": True, "data": {}}) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete( + proxy.artifact_read_range(artifact_id="art_004", offset=500, length=2048) + ) + finally: + loop.close() + + mock_handler.call_action.assert_called_once() + call_args = mock_handler.call_action.call_args + action_name = call_args[0][0] + payload = call_args[0][1] + + assert action_name == PluginToRuntimeAction.ARTIFACT_READ + assert payload["run_id"] == "run_001" + assert payload["artifact_id"] == "art_004" + assert payload["offset"] == 500 + assert payload["limit"] == 2048 + + def test_artifact_metadata_uses_run_id_from_context(self): + """artifact_metadata uses run_id from context, not from args.""" + import asyncio + + ctx = make_context() + ctx.run_id = "custom_run_id_123" # Custom run_id + mock_handler = MagicMock() + mock_handler.call_action = AsyncMock(return_value={"ok": True, "data": {}}) + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(proxy.artifact_metadata(artifact_id="art_001")) + finally: + loop.close() + + call_args = mock_handler.call_action.call_args + payload = call_args[0][1] + + # Verify that run_id comes from context + assert payload["run_id"] == "custom_run_id_123" + + +class TestArtifactActionEnums: + """Test artifact action enum values.""" + + def test_artifact_metadata_enum_exists(self): + """ARTIFACT_METADATA action enum exists.""" + assert hasattr(PluginToRuntimeAction, 'ARTIFACT_METADATA') + assert PluginToRuntimeAction.ARTIFACT_METADATA.value == "artifact_metadata" + + def test_artifact_read_enum_exists(self): + """ARTIFACT_READ action enum exists.""" + assert hasattr(PluginToRuntimeAction, 'ARTIFACT_READ') + assert PluginToRuntimeAction.ARTIFACT_READ.value == "artifact_read" From af781d30bfb163115713bd9bf220dd1b8f5b8bf0 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Sat, 23 May 2026 18:13:53 +0800 Subject: [PATCH 14/16] feat(agent-runner): extend artifact result payload --- .../entities/builtin/agent_runner/result.py | 60 +++++++++++++--- .../agent_runner/test_context_result.py | 70 +++++++++++++++++++ 2 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/result.py b/src/langbot_plugin/api/entities/builtin/agent_runner/result.py index c4a64c43..e5d8e095 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/result.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/result.py @@ -140,21 +140,65 @@ def artifact_created( mime_type: str | None = None, size: int | None = None, name: str | None = None, + *, + size_bytes: int | None = None, + sha256: str | None = None, + metadata: dict[str, typing.Any] | None = None, + content_base64: str | None = None, ) -> "AgentRunResult": """Create an artifact.created result. - Runner created an artifact. + Runner created an artifact that should be persisted by Host. + + Args: + run_id: Run identifier (must match current run) + artifact_id: Unique artifact identifier (recommended: UUID v4) + artifact_type: Type of artifact ('image', 'file', 'voice', 'tool_result', etc.) + mime_type: MIME type of the content + size: (Deprecated) Use size_bytes instead + name: Original file name + size_bytes: Size in bytes + sha256: SHA256 hash of content + metadata: Additional metadata (platform-specific info, etc.) + content_base64: Base64-encoded content for small artifacts. + For large artifacts, use external storage and omit this field. + Host will decode and store in BinaryStorage. + + Returns: + AgentRunResult with type="artifact.created" + + Note: + - Host sets conversation_id, run_id, runner_id from current context. + - Do NOT pass conversation_id/run_id in data; Host ignores them for security. + - For large artifacts (>1MB), consider using external storage and omitting content_base64. """ + # Handle backward compatibility: size -> size_bytes + if size_bytes is None and size is not None: + size_bytes = size + + data: dict[str, typing.Any] = { + "artifact_id": artifact_id, + "artifact_type": artifact_type, + } + + # Optional fields + if mime_type is not None: + data["mime_type"] = mime_type + if name is not None: + data["name"] = name + if size_bytes is not None: + data["size_bytes"] = size_bytes + if sha256 is not None: + data["sha256"] = sha256 + if metadata is not None: + data["metadata"] = metadata + if content_base64 is not None: + data["content_base64"] = content_base64 + return cls( run_id=run_id, type=AgentRunResultType.ARTIFACT_CREATED, - data={ - "artifact_id": artifact_id, - "artifact_type": artifact_type, - "mime_type": mime_type, - "size": size, - "name": name, - }, + data=data, ) @classmethod diff --git a/tests/api/entities/builtin/agent_runner/test_context_result.py b/tests/api/entities/builtin/agent_runner/test_context_result.py index 486e6c0a..b0ad6446 100644 --- a/tests/api/entities/builtin/agent_runner/test_context_result.py +++ b/tests/api/entities/builtin/agent_runner/test_context_result.py @@ -356,6 +356,76 @@ def test_artifact_created_validate(self): assert result.type == AgentRunResultType.ARTIFACT_CREATED assert result.data["artifact_id"] == "artifact_1" + def test_artifact_created_with_new_fields(self): + """Test artifact.created with all new fields.""" + import base64 + + content = b"test image content" + result = AgentRunResult.artifact_created( + run_id="run_1", + artifact_id="artifact_1", + artifact_type="image", + mime_type="image/png", + name="test.png", + size_bytes=len(content), + sha256="abc123", + metadata={"source": "generated"}, + content_base64=base64.b64encode(content).decode("utf-8"), + ) + + assert result.run_id == "run_1" + assert result.type == AgentRunResultType.ARTIFACT_CREATED + assert result.data["artifact_id"] == "artifact_1" + assert result.data["artifact_type"] == "image" + assert result.data["mime_type"] == "image/png" + assert result.data["name"] == "test.png" + assert result.data["size_bytes"] == len(content) + assert result.data["sha256"] == "abc123" + assert result.data["metadata"] == {"source": "generated"} + assert result.data["content_base64"] == base64.b64encode(content).decode("utf-8") + + def test_artifact_created_backward_compat_size(self): + """Test artifact.created backward compatibility: size -> size_bytes.""" + result = AgentRunResult.artifact_created( + run_id="run_1", + artifact_id="artifact_1", + artifact_type="file", + size=1024, # Deprecated parameter + ) + + # size should be mapped to size_bytes + assert result.data["size_bytes"] == 1024 + + def test_artifact_created_size_bytes_preferred(self): + """Test artifact.created prefers size_bytes over deprecated size.""" + result = AgentRunResult.artifact_created( + run_id="run_1", + artifact_id="artifact_1", + artifact_type="file", + size=2048, # Deprecated + size_bytes=1024, # Preferred + ) + + # size_bytes should take precedence + assert result.data["size_bytes"] == 1024 + + def test_artifact_created_metadata_only(self): + """Test artifact.created without content (metadata-only).""" + result = AgentRunResult.artifact_created( + run_id="run_1", + artifact_id="artifact_1", + artifact_type="file", + mime_type="application/pdf", + name="document.pdf", + size_bytes=1024, + sha256="abc123", + metadata={"source": "external"}, + ) + + assert result.data["artifact_id"] == "artifact_1" + # content_base64 is not added when not provided + assert "content_base64" not in result.data + def test_tool_call_started_validate(self): """Test tool.call.started result.""" result = AgentRunResult.tool_call_started( From 54fced215c349343bef3766dc402f3f3a4cbc734 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Sat, 23 May 2026 21:45:11 +0800 Subject: [PATCH 15/16] feat(agent-runner): forward pull APIs through runtime --- .../api/proxies/agent_run_api.py | 113 +++++ .../entities/io/actions/enums.py | 6 + .../runtime/io/handlers/plugin.py | 146 ++++++ .../runtime/plugin/container.py | 6 +- tests/api/proxies/test_agent_run_api_proxy.py | 160 ++++++ tests/runtime/test_pull_api_handlers.py | 462 ++++++++++++++++++ 6 files changed, 892 insertions(+), 1 deletion(-) create mode 100644 tests/runtime/test_pull_api_handlers.py diff --git a/src/langbot_plugin/api/proxies/agent_run_api.py b/src/langbot_plugin/api/proxies/agent_run_api.py index 87933484..05d5f3de 100644 --- a/src/langbot_plugin/api/proxies/agent_run_api.py +++ b/src/langbot_plugin/api/proxies/agent_run_api.py @@ -737,3 +737,116 @@ async def artifact_read_range( ArtifactReadResult as dict. """ return await self.artifact_read(artifact_id, offset=offset, limit=length) + + # ================= State APIs (run-scoped, policy-enforced) ================= + + async def state_get(self, scope: str, key: str) -> dict[str, Any]: + """Get a state value from host-owned state store. + + Args: + scope: State scope ('conversation', 'actor', 'subject', 'runner'). + key: State key (should use namespace prefix like 'external.*'). + + Returns: + Dict with 'value' key containing the stored value, or 'value': None + if key does not exist. + + Raises: + PermissionDeniedError: If scope not enabled by state_policy. + """ + timeout = self._bounded_timeout(default=15.0) + resp = await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.STATE_GET, + { + "run_id": self.run_id, + "scope": scope, + "key": key, + }, + timeout, + ) + return resp + + async def state_set(self, scope: str, key: str, value: Any) -> dict[str, Any]: + """Set a state value in host-owned state store. + + Args: + scope: State scope ('conversation', 'actor', 'subject', 'runner'). + key: State key (should use namespace prefix like 'external.*'). + value: State value (must be JSON-serializable, size-limited). + + Returns: + Dict with 'success' key. + + Raises: + PermissionDeniedError: If scope not enabled by state_policy. + """ + timeout = self._bounded_timeout(default=15.0) + resp = await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.STATE_SET, + { + "run_id": self.run_id, + "scope": scope, + "key": key, + "value": value, + }, + timeout, + ) + return resp + + async def state_delete(self, scope: str, key: str) -> dict[str, Any]: + """Delete a state value from host-owned state store. + + Args: + scope: State scope ('conversation', 'actor', 'subject', 'runner'). + key: State key to delete. + + Returns: + Dict with 'success' key (True if deleted, False if not found). + + Raises: + PermissionDeniedError: If scope not enabled by state_policy. + """ + timeout = self._bounded_timeout(default=15.0) + resp = await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.STATE_DELETE, + { + "run_id": self.run_id, + "scope": scope, + "key": key, + }, + timeout, + ) + return resp + + async def state_list( + self, + scope: str, + prefix: str | None = None, + limit: int = 100, + ) -> dict[str, Any]: + """List state keys in a scope. + + Args: + scope: State scope ('conversation', 'actor', 'subject', 'runner'). + prefix: Optional prefix to filter keys (e.g., 'external.'). + limit: Maximum number of keys to return (host-enforced cap of 100). + + Returns: + Dict with 'keys' key containing list of key names, and 'has_more' + boolean indicating if more keys are available. + + Raises: + PermissionDeniedError: If scope not enabled by state_policy. + """ + timeout = self._bounded_timeout(default=15.0) + resp = await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.STATE_LIST, + { + "run_id": self.run_id, + "scope": scope, + "prefix": prefix, + "limit": limit, + }, + timeout, + ) + return resp diff --git a/src/langbot_plugin/entities/io/actions/enums.py b/src/langbot_plugin/entities/io/actions/enums.py index 60d36f9b..91ee832f 100644 --- a/src/langbot_plugin/entities/io/actions/enums.py +++ b/src/langbot_plugin/entities/io/actions/enums.py @@ -89,6 +89,12 @@ class PluginToRuntimeAction(ActionType): ARTIFACT_METADATA = "artifact_metadata" ARTIFACT_READ = "artifact_read" + """Agent State APIs (run-scoped, requires run_id authorization)""" + STATE_GET = "state_get" + STATE_SET = "state_set" + STATE_DELETE = "state_delete" + STATE_LIST = "state_list" + class RuntimeToPluginAction(ActionType): """The action from runtime to plugin.""" diff --git a/src/langbot_plugin/runtime/io/handlers/plugin.py b/src/langbot_plugin/runtime/io/handlers/plugin.py index 80373117..4e36ed7e 100644 --- a/src/langbot_plugin/runtime/io/handlers/plugin.py +++ b/src/langbot_plugin/runtime/io/handlers/plugin.py @@ -675,6 +675,152 @@ async def list_plugins_manifest(data: dict[str, Any]) -> handler.ActionResponse: } ) + # ================= Agent History/Event/Artifact Pull API Handlers ================= + # These handlers forward pull API calls from plugin to LangBot Host with caller_plugin_identity injection. + + @self.action(PluginToRuntimeAction.HISTORY_PAGE) + async def history_page(data: dict[str, Any]) -> handler.ActionResponse: + """Forward HISTORY_PAGE to LangBot Host with caller_plugin_identity injection.""" + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + + result = await self.context.control_handler.call_action( + PluginToRuntimeAction.HISTORY_PAGE, + data, + timeout=30, + ) + return handler.ActionResponse.success(result) + + @self.action(PluginToRuntimeAction.HISTORY_SEARCH) + async def history_search(data: dict[str, Any]) -> handler.ActionResponse: + """Forward HISTORY_SEARCH to LangBot Host with caller_plugin_identity injection.""" + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + + result = await self.context.control_handler.call_action( + PluginToRuntimeAction.HISTORY_SEARCH, + data, + timeout=30, + ) + return handler.ActionResponse.success(result) + + @self.action(PluginToRuntimeAction.EVENT_GET) + async def event_get(data: dict[str, Any]) -> handler.ActionResponse: + """Forward EVENT_GET to LangBot Host with caller_plugin_identity injection.""" + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + + result = await self.context.control_handler.call_action( + PluginToRuntimeAction.EVENT_GET, + data, + timeout=15, + ) + return handler.ActionResponse.success(result) + + @self.action(PluginToRuntimeAction.EVENT_PAGE) + async def event_page(data: dict[str, Any]) -> handler.ActionResponse: + """Forward EVENT_PAGE to LangBot Host with caller_plugin_identity injection.""" + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + + result = await self.context.control_handler.call_action( + PluginToRuntimeAction.EVENT_PAGE, + data, + timeout=30, + ) + return handler.ActionResponse.success(result) + + @self.action(PluginToRuntimeAction.ARTIFACT_METADATA) + async def artifact_metadata(data: dict[str, Any]) -> handler.ActionResponse: + """Forward ARTIFACT_METADATA to LangBot Host with caller_plugin_identity injection.""" + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + + result = await self.context.control_handler.call_action( + PluginToRuntimeAction.ARTIFACT_METADATA, + data, + timeout=15, + ) + return handler.ActionResponse.success(result) + + @self.action(PluginToRuntimeAction.ARTIFACT_READ) + async def artifact_read(data: dict[str, Any]) -> handler.ActionResponse: + """Forward ARTIFACT_READ to LangBot Host with caller_plugin_identity injection.""" + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + + result = await self.context.control_handler.call_action( + PluginToRuntimeAction.ARTIFACT_READ, + data, + timeout=60, + ) + return handler.ActionResponse.success(result) + + # ================= Agent State Pull API Handlers ================= + # These handlers forward State API calls from plugin to LangBot Host with caller_plugin_identity injection. + + @self.action(PluginToRuntimeAction.STATE_GET) + async def state_get(data: dict[str, Any]) -> handler.ActionResponse: + """Forward STATE_GET to LangBot Host with caller_plugin_identity injection.""" + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + + result = await self.context.control_handler.call_action( + PluginToRuntimeAction.STATE_GET, + data, + timeout=15, + ) + return handler.ActionResponse.success(result) + + @self.action(PluginToRuntimeAction.STATE_SET) + async def state_set(data: dict[str, Any]) -> handler.ActionResponse: + """Forward STATE_SET to LangBot Host with caller_plugin_identity injection.""" + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + + result = await self.context.control_handler.call_action( + PluginToRuntimeAction.STATE_SET, + data, + timeout=15, + ) + return handler.ActionResponse.success(result) + + @self.action(PluginToRuntimeAction.STATE_DELETE) + async def state_delete(data: dict[str, Any]) -> handler.ActionResponse: + """Forward STATE_DELETE to LangBot Host with caller_plugin_identity injection.""" + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + + result = await self.context.control_handler.call_action( + PluginToRuntimeAction.STATE_DELETE, + data, + timeout=15, + ) + return handler.ActionResponse.success(result) + + @self.action(PluginToRuntimeAction.STATE_LIST) + async def state_list(data: dict[str, Any]) -> handler.ActionResponse: + """Forward STATE_LIST to LangBot Host with caller_plugin_identity injection.""" + caller_identity = _get_caller_plugin_identity(self) + if caller_identity: + data["caller_plugin_identity"] = caller_identity + + result = await self.context.control_handler.call_action( + PluginToRuntimeAction.STATE_LIST, + data, + timeout=15, + ) + return handler.ActionResponse.success(result) + async def initialize_plugin( self, plugin_settings: dict[str, Any] ) -> dict[str, Any]: diff --git a/src/langbot_plugin/runtime/plugin/container.py b/src/langbot_plugin/runtime/plugin/container.py index 57bb927d..1bf52857 100644 --- a/src/langbot_plugin/runtime/plugin/container.py +++ b/src/langbot_plugin/runtime/plugin/container.py @@ -6,11 +6,15 @@ import enum import pydantic +from typing import TYPE_CHECKING + from langbot_plugin.api.definition.plugin import NonePlugin from langbot_plugin.api.definition.plugin import BasePlugin from langbot_plugin.api.definition.components.base import BaseComponent, NoneComponent from langbot_plugin.api.definition.components.manifest import ComponentManifest -from langbot_plugin.runtime.io.handlers.plugin import PluginConnectionHandler + +if TYPE_CHECKING: + from langbot_plugin.runtime.io.handlers.plugin import PluginConnectionHandler class RuntimeContainerStatus(enum.Enum): diff --git a/tests/api/proxies/test_agent_run_api_proxy.py b/tests/api/proxies/test_agent_run_api_proxy.py index 307e45d6..0244fbc9 100644 --- a/tests/api/proxies/test_agent_run_api_proxy.py +++ b/tests/api/proxies/test_agent_run_api_proxy.py @@ -745,3 +745,163 @@ async def test_retrieve_knowledge_sends_correct_fields(self): assert 'query_text' in data assert 'top_k' in data assert 'filters' in data + + +class TestAgentRunAPIProxyStateAPI: + """Tests for State API proxy methods.""" + + @pytest.mark.anyio + async def test_state_get_sends_correct_fields(self): + """STATE_GET: SDK fields match Host handler expectations.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'value': {'key': 'value'}} + + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + result = await proxy.state_get('conversation', 'external.session_id') + + call_args = mock_handler.call_action_mock.call_args + data = call_args[0][1] + + assert 'run_id' in data + assert data['run_id'] == 'test_run' + assert 'scope' in data + assert data['scope'] == 'conversation' + assert 'key' in data + assert data['key'] == 'external.session_id' + + @pytest.mark.anyio + async def test_state_set_sends_correct_fields(self): + """STATE_SET: SDK fields match Host handler expectations.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'success': True} + + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + result = await proxy.state_set('conversation', 'external.session_id', 'sess_123') + + call_args = mock_handler.call_action_mock.call_args + data = call_args[0][1] + + assert 'run_id' in data + assert data['run_id'] == 'test_run' + assert 'scope' in data + assert data['scope'] == 'conversation' + assert 'key' in data + assert data['key'] == 'external.session_id' + assert 'value' in data + assert data['value'] == 'sess_123' + + @pytest.mark.anyio + async def test_state_delete_sends_correct_fields(self): + """STATE_DELETE: SDK fields match Host handler expectations.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'success': True} + + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + result = await proxy.state_delete('conversation', 'external.session_id') + + call_args = mock_handler.call_action_mock.call_args + data = call_args[0][1] + + assert 'run_id' in data + assert data['run_id'] == 'test_run' + assert 'scope' in data + assert data['scope'] == 'conversation' + assert 'key' in data + assert data['key'] == 'external.session_id' + + @pytest.mark.anyio + async def test_state_list_sends_correct_fields(self): + """STATE_LIST: SDK fields match Host handler expectations.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'keys': ['key1', 'key2'], 'has_more': False} + + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + result = await proxy.state_list('conversation', prefix='external.', limit=50) + + call_args = mock_handler.call_action_mock.call_args + data = call_args[0][1] + + assert 'run_id' in data + assert data['run_id'] == 'test_run' + assert 'scope' in data + assert data['scope'] == 'conversation' + assert 'prefix' in data + assert data['prefix'] == 'external.' + assert 'limit' in data + assert data['limit'] == 50 + + @pytest.mark.anyio + async def test_state_get_returns_value(self): + """STATE_GET returns value from response.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'value': {'nested': 'data'}} + + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + result = await proxy.state_get('conversation', 'test_key') + + assert result['value'] == {'nested': 'data'} + + @pytest.mark.anyio + async def test_state_set_returns_success(self): + """STATE_SET returns success status.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'success': True} + + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + result = await proxy.state_set('conversation', 'test_key', 'test_value') + + assert result['success'] is True + + @pytest.mark.anyio + async def test_state_list_returns_keys_and_has_more(self): + """STATE_LIST returns keys and has_more flag.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'keys': ['k1', 'k2', 'k3'], 'has_more': True} + + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + result = await proxy.state_list('runner', limit=100) + + assert result['keys'] == ['k1', 'k2', 'k3'] + assert result['has_more'] is True + + @pytest.mark.anyio + async def test_state_methods_use_correct_action_enum(self): + """State methods should use correct PluginToRuntimeAction enum values.""" + mock_handler = MockHandler() + mock_handler.call_action_mock.return_value = {'value': None} + + ctx = create_mock_context() + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + # Test each method uses correct action + await proxy.state_get('conversation', 'key') + call_args = mock_handler.call_action_mock.call_args + assert call_args[0][0] == PluginToRuntimeAction.STATE_GET + + mock_handler.call_action_mock.return_value = {'success': True} + await proxy.state_set('conversation', 'key', 'value') + call_args = mock_handler.call_action_mock.call_args + assert call_args[0][0] == PluginToRuntimeAction.STATE_SET + + await proxy.state_delete('conversation', 'key') + call_args = mock_handler.call_action_mock.call_args + assert call_args[0][0] == PluginToRuntimeAction.STATE_DELETE + + mock_handler.call_action_mock.return_value = {'keys': [], 'has_more': False} + await proxy.state_list('conversation') + call_args = mock_handler.call_action_mock.call_args + assert call_args[0][0] == PluginToRuntimeAction.STATE_LIST diff --git a/tests/runtime/test_pull_api_handlers.py b/tests/runtime/test_pull_api_handlers.py new file mode 100644 index 00000000..2d1f8409 --- /dev/null +++ b/tests/runtime/test_pull_api_handlers.py @@ -0,0 +1,462 @@ +"""Tests for SDK PluginConnectionHandler runtime action forwarding. + +Tests focus on: +- State API handlers (STATE_GET, STATE_SET, STATE_DELETE, STATE_LIST) +- History/Event API handlers (HISTORY_PAGE, HISTORY_SEARCH, EVENT_GET, EVENT_PAGE) +- Artifact API handlers (ARTIFACT_METADATA, ARTIFACT_READ) +- caller_plugin_identity injection in all pull API handlers + +These tests instantiate real PluginConnectionHandler and verify: +- Actions are registered in handler.actions +- Forwarding calls context.control_handler.call_action with correct action and payload +- caller_plugin_identity is injected from plugin container when available +""" +from __future__ import annotations + +import pytest +from unittest.mock import AsyncMock, MagicMock +from types import SimpleNamespace + +from langbot_plugin.runtime.io.handlers.plugin import PluginConnectionHandler +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + + +class FakeConnection: + """Minimal fake connection for PluginConnectionHandler testing.""" + + async def send(self, message: str) -> None: + pass + + async def receive(self) -> str: + return "" + + async def close(self) -> None: + pass + + +def make_fake_context(): + """Create a minimal fake runtime context for testing.""" + control_handler = SimpleNamespace() + control_handler.call_action = AsyncMock(return_value={"ok": True}) + + plugin_mgr = SimpleNamespace() + plugin_mgr.plugins = [] + + context = SimpleNamespace() + context.control_handler = control_handler + context.plugin_mgr = plugin_mgr + + return context + + +class TestPluginConnectionHandlerPullAPIRegistration: + """Tests for pull API handler registration in PluginConnectionHandler.""" + + @pytest.mark.anyio + async def test_all_pull_api_handlers_registered(self): + """All pull API handlers are registered in handler.actions.""" + fake_context = make_fake_context() + handler = PluginConnectionHandler(FakeConnection(), fake_context) + + # Verify all pull API actions are registered + expected_actions = [ + PluginToRuntimeAction.HISTORY_PAGE, + PluginToRuntimeAction.HISTORY_SEARCH, + PluginToRuntimeAction.EVENT_GET, + PluginToRuntimeAction.EVENT_PAGE, + PluginToRuntimeAction.ARTIFACT_METADATA, + PluginToRuntimeAction.ARTIFACT_READ, + PluginToRuntimeAction.STATE_GET, + PluginToRuntimeAction.STATE_SET, + PluginToRuntimeAction.STATE_DELETE, + PluginToRuntimeAction.STATE_LIST, + ] + + for action in expected_actions: + assert action.value in handler.actions, f"Action {action.value} not registered" + + +class TestPluginConnectionHandlerPullAPIForwarding: + """Tests for pull API action forwarding to control_handler.call_action.""" + + @pytest.mark.anyio + async def test_state_get_forwards_correctly(self): + """STATE_GET forwards to control_handler.call_action with correct parameters.""" + fake_context = make_fake_context() + handler = PluginConnectionHandler(FakeConnection(), fake_context) + + payload = {"run_id": "run_001", "scope": "conversation", "key": "external.test_key"} + resp = await handler.actions[PluginToRuntimeAction.STATE_GET.value](payload) + + assert resp.code == 0 + assert resp.data == {"ok": True} + fake_context.control_handler.call_action.assert_called_once() + + call_args = fake_context.control_handler.call_action.call_args + assert call_args[0][0] == PluginToRuntimeAction.STATE_GET + + forwarded_payload = call_args[0][1] + assert forwarded_payload["run_id"] == "run_001" + assert forwarded_payload["scope"] == "conversation" + assert forwarded_payload["key"] == "external.test_key" + + # timeout is passed as keyword argument + assert call_args[1].get("timeout") == 15 + + @pytest.mark.anyio + async def test_state_set_forwards_correctly(self): + """STATE_SET forwards to control_handler.call_action with correct parameters.""" + fake_context = make_fake_context() + handler = PluginConnectionHandler(FakeConnection(), fake_context) + + payload = { + "run_id": "run_001", + "scope": "conversation", + "key": "external.test_key", + "value": {"data": "test_value"}, + } + resp = await handler.actions[PluginToRuntimeAction.STATE_SET.value](payload) + + assert resp.code == 0 + fake_context.control_handler.call_action.assert_called_once() + + call_args = fake_context.control_handler.call_action.call_args + assert call_args[0][0] == PluginToRuntimeAction.STATE_SET + + forwarded_payload = call_args[0][1] + assert forwarded_payload["run_id"] == "run_001" + assert forwarded_payload["scope"] == "conversation" + assert forwarded_payload["key"] == "external.test_key" + assert forwarded_payload["value"] == {"data": "test_value"} + + # timeout is passed as keyword argument + assert call_args[1].get("timeout") == 15 + + @pytest.mark.anyio + async def test_state_delete_forwards_correctly(self): + """STATE_DELETE forwards to control_handler.call_action with correct parameters.""" + fake_context = make_fake_context() + handler = PluginConnectionHandler(FakeConnection(), fake_context) + + payload = {"run_id": "run_001", "scope": "conversation", "key": "external.test_key"} + resp = await handler.actions[PluginToRuntimeAction.STATE_DELETE.value](payload) + + assert resp.code == 0 + fake_context.control_handler.call_action.assert_called_once() + + call_args = fake_context.control_handler.call_action.call_args + assert call_args[0][0] == PluginToRuntimeAction.STATE_DELETE + assert call_args[1].get("timeout") == 15 + + @pytest.mark.anyio + async def test_state_list_forwards_correctly(self): + """STATE_LIST forwards to control_handler.call_action with correct parameters.""" + fake_context = make_fake_context() + handler = PluginConnectionHandler(FakeConnection(), fake_context) + + payload = {"run_id": "run_001", "scope": "conversation", "prefix": "external.", "limit": 50} + resp = await handler.actions[PluginToRuntimeAction.STATE_LIST.value](payload) + + assert resp.code == 0 + fake_context.control_handler.call_action.assert_called_once() + + call_args = fake_context.control_handler.call_action.call_args + assert call_args[0][0] == PluginToRuntimeAction.STATE_LIST + + forwarded_payload = call_args[0][1] + assert forwarded_payload["run_id"] == "run_001" + assert forwarded_payload["scope"] == "conversation" + assert forwarded_payload["prefix"] == "external." + assert forwarded_payload["limit"] == 50 + assert call_args[1].get("timeout") == 15 + + @pytest.mark.anyio + async def test_history_page_forwards_correctly(self): + """HISTORY_PAGE forwards to control_handler.call_action with correct parameters.""" + fake_context = make_fake_context() + handler = PluginConnectionHandler(FakeConnection(), fake_context) + + payload = {"run_id": "run_001", "limit": 50, "cursor": None} + resp = await handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value](payload) + + assert resp.code == 0 + fake_context.control_handler.call_action.assert_called_once() + + call_args = fake_context.control_handler.call_action.call_args + assert call_args[0][0] == PluginToRuntimeAction.HISTORY_PAGE + + forwarded_payload = call_args[0][1] + assert forwarded_payload["run_id"] == "run_001" + assert forwarded_payload["limit"] == 50 + + # timeout is passed as keyword argument + assert call_args[1].get("timeout") == 30 + + @pytest.mark.anyio + async def test_history_search_forwards_correctly(self): + """HISTORY_SEARCH forwards to control_handler.call_action with correct parameters.""" + fake_context = make_fake_context() + handler = PluginConnectionHandler(FakeConnection(), fake_context) + + payload = {"run_id": "run_001", "query": "test query", "top_k": 10} + resp = await handler.actions[PluginToRuntimeAction.HISTORY_SEARCH.value](payload) + + assert resp.code == 0 + fake_context.control_handler.call_action.assert_called_once() + + call_args = fake_context.control_handler.call_action.call_args + assert call_args[0][0] == PluginToRuntimeAction.HISTORY_SEARCH + assert call_args[1].get("timeout") == 30 + + @pytest.mark.anyio + async def test_event_get_forwards_correctly(self): + """EVENT_GET forwards to control_handler.call_action with correct parameters.""" + fake_context = make_fake_context() + handler = PluginConnectionHandler(FakeConnection(), fake_context) + + payload = {"run_id": "run_001", "event_id": "event_001"} + resp = await handler.actions[PluginToRuntimeAction.EVENT_GET.value](payload) + + assert resp.code == 0 + fake_context.control_handler.call_action.assert_called_once() + + call_args = fake_context.control_handler.call_action.call_args + assert call_args[0][0] == PluginToRuntimeAction.EVENT_GET + + forwarded_payload = call_args[0][1] + assert forwarded_payload["run_id"] == "run_001" + assert forwarded_payload["event_id"] == "event_001" + + # timeout is passed as keyword argument + assert call_args[1].get("timeout") == 15 + + @pytest.mark.anyio + async def test_event_page_forwards_correctly(self): + """EVENT_PAGE forwards to control_handler.call_action with correct parameters.""" + fake_context = make_fake_context() + handler = PluginConnectionHandler(FakeConnection(), fake_context) + + payload = {"run_id": "run_001", "limit": 50} + resp = await handler.actions[PluginToRuntimeAction.EVENT_PAGE.value](payload) + + assert resp.code == 0 + fake_context.control_handler.call_action.assert_called_once() + + call_args = fake_context.control_handler.call_action.call_args + assert call_args[0][0] == PluginToRuntimeAction.EVENT_PAGE + assert call_args[1].get("timeout") == 30 + + @pytest.mark.anyio + async def test_artifact_metadata_forwards_correctly(self): + """ARTIFACT_METADATA forwards to control_handler.call_action with correct parameters.""" + fake_context = make_fake_context() + handler = PluginConnectionHandler(FakeConnection(), fake_context) + + payload = {"run_id": "run_001", "artifact_id": "artifact_001"} + resp = await handler.actions[PluginToRuntimeAction.ARTIFACT_METADATA.value](payload) + + assert resp.code == 0 + fake_context.control_handler.call_action.assert_called_once() + + call_args = fake_context.control_handler.call_action.call_args + assert call_args[0][0] == PluginToRuntimeAction.ARTIFACT_METADATA + + forwarded_payload = call_args[0][1] + assert forwarded_payload["run_id"] == "run_001" + assert forwarded_payload["artifact_id"] == "artifact_001" + + # timeout is passed as keyword argument + assert call_args[1].get("timeout") == 15 + + @pytest.mark.anyio + async def test_artifact_read_forwards_correctly(self): + """ARTIFACT_READ forwards to control_handler.call_action with correct parameters.""" + fake_context = make_fake_context() + handler = PluginConnectionHandler(FakeConnection(), fake_context) + + payload = { + "run_id": "run_001", + "artifact_id": "artifact_001", + "offset": 0, + "limit": 1024, + } + resp = await handler.actions[PluginToRuntimeAction.ARTIFACT_READ.value](payload) + + assert resp.code == 0 + fake_context.control_handler.call_action.assert_called_once() + + call_args = fake_context.control_handler.call_action.call_args + assert call_args[0][0] == PluginToRuntimeAction.ARTIFACT_READ + + forwarded_payload = call_args[0][1] + assert forwarded_payload["run_id"] == "run_001" + assert forwarded_payload["artifact_id"] == "artifact_001" + assert forwarded_payload["offset"] == 0 + assert forwarded_payload["limit"] == 1024 + + # timeout is passed as keyword argument + assert call_args[1].get("timeout") == 60 + + +class TestPluginConnectionHandlerCallerIdentity: + """Tests for caller_plugin_identity injection from plugin container.""" + + @pytest.mark.anyio + async def test_caller_plugin_identity_injected_when_plugin_matches(self): + """caller_plugin_identity is injected when handler matches a plugin container.""" + fake_context = make_fake_context() + handler = PluginConnectionHandler(FakeConnection(), fake_context) + + # Create a fake plugin container that references this handler + plugin_container = SimpleNamespace( + _runtime_plugin_handler=handler, + manifest=SimpleNamespace( + metadata=SimpleNamespace( + author="test-author", + name="test-plugin", + ) + ), + ) + fake_context.plugin_mgr.plugins = [plugin_container] + + payload = {"run_id": "run_001", "scope": "conversation", "key": "external.k"} + resp = await handler.actions[PluginToRuntimeAction.STATE_GET.value](payload) + + assert resp.code == 0 + fake_context.control_handler.call_action.assert_called_once() + + call_args = fake_context.control_handler.call_action.call_args + forwarded_payload = call_args[0][1] + assert forwarded_payload["caller_plugin_identity"] == "test-author/test-plugin" + + @pytest.mark.anyio + async def test_no_caller_plugin_identity_when_no_plugin_container(self): + """caller_plugin_identity is NOT injected when no plugin container matches.""" + fake_context = make_fake_context() + handler = PluginConnectionHandler(FakeConnection(), fake_context) + + # No plugin containers + fake_context.plugin_mgr.plugins = [] + + payload = {"run_id": "run_001", "scope": "conversation", "key": "external.k"} + resp = await handler.actions[PluginToRuntimeAction.STATE_GET.value](payload) + + assert resp.code == 0 + fake_context.control_handler.call_action.assert_called_once() + + call_args = fake_context.control_handler.call_action.call_args + forwarded_payload = call_args[0][1] + assert "caller_plugin_identity" not in forwarded_payload + + @pytest.mark.anyio + async def test_no_caller_plugin_identity_when_handler_not_matched(self): + """caller_plugin_identity is NOT injected when plugin container doesn't reference this handler.""" + fake_context = make_fake_context() + handler = PluginConnectionHandler(FakeConnection(), fake_context) + + # Plugin container references a different handler + other_handler = SimpleNamespace() + plugin_container = SimpleNamespace( + _runtime_plugin_handler=other_handler, # Different handler + manifest=SimpleNamespace( + metadata=SimpleNamespace( + author="other-author", + name="other-plugin", + ) + ), + ) + fake_context.plugin_mgr.plugins = [plugin_container] + + payload = {"run_id": "run_001", "scope": "conversation", "key": "external.k"} + resp = await handler.actions[PluginToRuntimeAction.STATE_GET.value](payload) + + assert resp.code == 0 + call_args = fake_context.control_handler.call_action.call_args + forwarded_payload = call_args[0][1] + assert "caller_plugin_identity" not in forwarded_payload + + @pytest.mark.anyio + async def test_all_pull_apis_inject_caller_plugin_identity(self): + """All pull API handlers inject caller_plugin_identity when plugin container matches.""" + fake_context = make_fake_context() + handler = PluginConnectionHandler(FakeConnection(), fake_context) + + # Create a fake plugin container + plugin_container = SimpleNamespace( + _runtime_plugin_handler=handler, + manifest=SimpleNamespace( + metadata=SimpleNamespace( + author="my-author", + name="my-plugin", + ) + ), + ) + fake_context.plugin_mgr.plugins = [plugin_container] + + # Test all pull API actions + pull_actions = [ + (PluginToRuntimeAction.STATE_GET, {"run_id": "r", "scope": "conversation", "key": "k"}), + (PluginToRuntimeAction.STATE_SET, {"run_id": "r", "scope": "conversation", "key": "k", "value": {}}), + (PluginToRuntimeAction.STATE_DELETE, {"run_id": "r", "scope": "conversation", "key": "k"}), + (PluginToRuntimeAction.STATE_LIST, {"run_id": "r", "scope": "conversation"}), + (PluginToRuntimeAction.HISTORY_PAGE, {"run_id": "r", "limit": 10}), + (PluginToRuntimeAction.HISTORY_SEARCH, {"run_id": "r", "query": "q"}), + (PluginToRuntimeAction.EVENT_GET, {"run_id": "r", "event_id": "e"}), + (PluginToRuntimeAction.EVENT_PAGE, {"run_id": "r", "limit": 10}), + (PluginToRuntimeAction.ARTIFACT_METADATA, {"run_id": "r", "artifact_id": "a"}), + (PluginToRuntimeAction.ARTIFACT_READ, {"run_id": "r", "artifact_id": "a", "offset": 0, "limit": 100}), + ] + + for action, payload in pull_actions: + fake_context.control_handler.call_action.reset_mock() + + resp = await handler.actions[action.value](payload) + + assert resp.code == 0, f"Action {action.value} returned error" + + call_args = fake_context.control_handler.call_action.call_args + forwarded_payload = call_args[0][1] + assert forwarded_payload.get("caller_plugin_identity") == "my-author/my-plugin", \ + f"caller_plugin_identity not injected for {action.value}" + + +class TestAgentRunAPIProxyPullAPIPayloads: + """Tests for pull API payload structure via AgentRunAPIProxy. + + These tests verify the proxy layer sends correct payloads to the mock handler. + """ + + @pytest.mark.anyio + async def test_state_api_payloads_via_proxy(self): + """State API payloads are correctly forwarded via proxy.""" + from langbot_plugin.api.proxies.agent_run_api import AgentRunAPIProxy + from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext + from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources + from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger + from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput + from langbot_plugin.api.entities.builtin.agent_runner.event import AgentEventContext + from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext + + mock_handler = SimpleNamespace() + mock_handler.call_action = AsyncMock(return_value={"value": None, "success": True}) + + ctx = AgentRunContext( + run_id="proxy_run", + trigger=AgentTrigger(type="user_message"), + event=AgentEventContext(event_id="e1", event_type="test", source="test"), + input=AgentInput(content="test"), + delivery=DeliveryContext(surface="test"), + runtime=AgentRuntimeContext(query_id=1), + resources=AgentResources(), + ) + + proxy = AgentRunAPIProxy(ctx=ctx, plugin_runtime_handler=mock_handler) + + await proxy.state_get("conversation", "key") + + call_args = mock_handler.call_action.call_args + assert call_args[0][0] == PluginToRuntimeAction.STATE_GET + assert call_args[0][1]["run_id"] == "proxy_run" + assert call_args[0][1]["scope"] == "conversation" + assert call_args[0][1]["key"] == "key" From fd5abba8074614c62a8bb98742077c5a6956f5f6 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Sun, 24 May 2026 09:13:15 +0800 Subject: [PATCH 16/16] feat(agent-runner): rename compatibility context to adapter --- .../components/agent_runner/runner.py | 2 +- .../entities/builtin/agent_runner/__init__.py | 4 ++-- .../builtin/agent_runner/bootstrap.py | 2 +- .../entities/builtin/agent_runner/context.py | 20 ++++++++--------- .../entities/builtin/agent_runner/event.py | 8 +++---- .../entities/builtin/agent_runner/input.py | 2 +- .../entities/builtin/agent_runner/legacy.py | 4 ++-- .../entities/builtin/agent_runner/result.py | 6 ++--- .../entities/builtin/agent_runner/runtime.py | 2 +- .../entities/builtin/agent_runner/trigger.py | 6 ++--- .../agent_runner/test_context_result.py | 22 +++++++++---------- 11 files changed, 39 insertions(+), 39 deletions(-) diff --git a/src/langbot_plugin/api/definition/components/agent_runner/runner.py b/src/langbot_plugin/api/definition/components/agent_runner/runner.py index 3ae52167..09e69f6d 100644 --- a/src/langbot_plugin/api/definition/components/agent_runner/runner.py +++ b/src/langbot_plugin/api/definition/components/agent_runner/runner.py @@ -136,7 +136,7 @@ async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None - runtime: Host/environment info (version, query_id, trace_id, deadline) - config: Runner instance configuration - bootstrap: Optional bootstrap messages (NOT core history) - - compatibility: Legacy compatibility fields + - adapter: Pipeline adapter / host adapter metadata Yields: AgentRunResult: Progress and final result events: diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py b/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py index 9d52eef2..04b75bba 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py @@ -48,7 +48,7 @@ from langbot_plugin.api.entities.builtin.agent_runner.bootstrap import BootstrapContext from langbot_plugin.api.entities.builtin.agent_runner.context import ( AgentRunContext, - CompatibilityContext, + AdapterContext, ) from langbot_plugin.api.entities.builtin.agent_runner.result import ( AgentRunResult, @@ -102,7 +102,7 @@ "ContextAPICapabilities", "DeliveryContext", "BootstrapContext", - "CompatibilityContext", + "AdapterContext", # Main context and result "AgentRunContext", "AgentRunResult", diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/bootstrap.py b/src/langbot_plugin/api/entities/builtin/agent_runner/bootstrap.py index 2de4f037..e55c41f4 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/bootstrap.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/bootstrap.py @@ -20,7 +20,7 @@ class BootstrapContext(pydantic.BaseModel): - bootstrap.messages is Host convenience, NOT protocol core. - Self-managed context runners should receive empty bootstrap or only current event. - Host MUST NOT inline full transcript just to "help" the agent. - - Legacy max-round only affects compatibility adapter, NOT Protocol v1 fields. + - Pipeline adapter max-round only affects adapter bootstrap, NOT Protocol v1 fields. """ messages: list[Message] = pydantic.Field(default_factory=list) diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/context.py b/src/langbot_plugin/api/entities/builtin/agent_runner/context.py index d5eedb74..e542241f 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/context.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/context.py @@ -22,10 +22,10 @@ from langbot_plugin.api.entities.builtin.agent_runner.bootstrap import BootstrapContext -class CompatibilityContext(pydantic.BaseModel): - """Compatibility context for legacy Query/Pipeline migration. +class AdapterContext(pydantic.BaseModel): + """Context for Pipeline adapter / host adapter metadata. - This context holds legacy fields during migration from Query-first to event-first. + This context holds adapter-specific fields for transition from Query/Pipeline. Runners SHOULD NOT depend on this for long-term capabilities. """ @@ -36,13 +36,13 @@ class CompatibilityContext(pydantic.BaseModel): """Legacy pipeline UUID.""" max_round: int | None = None - """Legacy max-round (for reference only, should NOT be used by new runners).""" + """Pipeline adapter max-round (for reference only, should NOT be used by new runners).""" - legacy_messages: list[Message] = pydantic.Field(default_factory=list) - """Legacy messages field (prefer using bootstrap.messages or history API).""" + adapter_messages: list[Message] = pydantic.Field(default_factory=list) + """Messages from Pipeline adapter / bootstrap (prefer using bootstrap.messages or history API).""" extra: dict[str, typing.Any] = pydantic.Field(default_factory=dict) - """Other legacy fields.""" + """Other adapter-specific fields.""" class AgentRunContext(pydantic.BaseModel): @@ -52,7 +52,7 @@ class AgentRunContext(pydantic.BaseModel): - event is REQUIRED (not optional) - input is REQUIRED (current event input, not history) - messages is DEMOTED to bootstrap (optional convenience) - - compatibility holds legacy Query/Pipeline fields + - adapter holds Pipeline adapter / host adapter metadata Field boundaries: - config: Static runner configuration from pipeline/runner config. @@ -115,8 +115,8 @@ class AgentRunContext(pydantic.BaseModel): bootstrap: BootstrapContext | None = None """Optional bootstrap context (small convenience window, NOT full history).""" - compatibility: CompatibilityContext | None = None - """Compatibility context for legacy Query/Pipeline fields. + adapter: AdapterContext | None = None + """Adapter context for Pipeline adapter / host adapter metadata. Runners SHOULD NOT depend on this for long-term capabilities. """ diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/event.py b/src/langbot_plugin/api/entities/builtin/agent_runner/event.py index 24fd3e99..1f5e25e6 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/event.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/event.py @@ -46,12 +46,12 @@ class ConversationContext(pydantic.BaseModel): workspace_id: str | None = None """Workspace ID (for multi-tenant scenarios).""" - # Legacy fields for backward compatibility + # Pipeline adapter fields session_id: str | None = None - """Session identifier (legacy, prefer conversation_id).""" + """Pipeline session identifier (prefer conversation_id for stable identity).""" pipeline_uuid: str | None = None - """Pipeline UUID (legacy).""" + """Pipeline UUID.""" class AgentEventContext(pydantic.BaseModel): @@ -70,7 +70,7 @@ class AgentEventContext(pydantic.BaseModel): """Event timestamp (epoch seconds).""" source: str - """Event source (platform, webui, api, scheduler, system, pipeline_compat).""" + """Event source (platform, webui, api, scheduler, system, pipeline_adapter).""" source_event_type: str | None = None """Original platform event type (for debugging/logging).""" diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/input.py b/src/langbot_plugin/api/entities/builtin/agent_runner/input.py index bc51fa1f..8068397c 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/input.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/input.py @@ -44,7 +44,7 @@ class AgentInput(pydantic.BaseModel): """Structured content elements (text, images, files, etc.).""" message_chain: list[dict[str, typing.Any]] | dict[str, typing.Any] | None = None - """Raw platform message chain (compatibility field, not stable dependency).""" + """Raw platform message chain reference (adapter field, not stable dependency).""" attachments: list[ArtifactRef] = pydantic.Field(default_factory=list) """Artifact references for files/images/attachments.""" diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/legacy.py b/src/langbot_plugin/api/entities/builtin/agent_runner/legacy.py index 6b4c9283..7f7508c8 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/legacy.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/legacy.py @@ -65,7 +65,7 @@ def to_v1_result(self, run_id: str = "legacy") -> AgentRunResult: WARNING: This is a migration helper only. Args: - run_id: Run identifier (defaults to "legacy" for backward compatibility). + run_id: Run identifier (defaults to "legacy" for migration helpers). Callers should pass the actual run_id from context if available. """ warnings.warn( @@ -303,6 +303,6 @@ def create_legacy_context( runtime=runtime, config=extra_config, bootstrap=bootstrap, # Historical messages go here - compatibility=None, + adapter=None, metadata={}, ) diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/result.py b/src/langbot_plugin/api/entities/builtin/agent_runner/result.py index e5d8e095..84d17b2a 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/result.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/result.py @@ -172,7 +172,7 @@ def artifact_created( - Do NOT pass conversation_id/run_id in data; Host ignores them for security. - For large artifacts (>1MB), consider using external storage and omitting content_base64. """ - # Handle backward compatibility: size -> size_bytes + # Accept the concise size alias and normalize it to protocol field name. if size_bytes is None and size is not None: size_bytes = size @@ -219,7 +219,7 @@ def state_updated( key: State key, should use namespace prefix (e.g., external.conversation_id) value: State value, must be JSON-serializable scope: State scope - one of: conversation, actor, subject, runner. - Defaults to "conversation" for backward compatibility. + Defaults to "conversation" when omitted. Returns: AgentRunResult with type="state.updated" and data containing scope/key/value. @@ -309,4 +309,4 @@ def action_requested( "target": target, "payload": payload, }, - ) \ No newline at end of file + ) diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/runtime.py b/src/langbot_plugin/api/entities/builtin/agent_runner/runtime.py index 4ae752dd..b94895d4 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/runtime.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/runtime.py @@ -19,7 +19,7 @@ class AgentRuntimeContext(pydantic.BaseModel): """SDK protocol version.""" query_id: int | None = None - """Query ID (for legacy compatibility).""" + """Pipeline query ID when the run enters through Pipeline adapter.""" trace_id: str | None = None """Trace ID for observability.""" diff --git a/src/langbot_plugin/api/entities/builtin/agent_runner/trigger.py b/src/langbot_plugin/api/entities/builtin/agent_runner/trigger.py index 57e9585d..f63e5aeb 100644 --- a/src/langbot_plugin/api/entities/builtin/agent_runner/trigger.py +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/trigger.py @@ -21,8 +21,8 @@ class AgentTrigger(pydantic.BaseModel): "api", "scheduler", "system", - "pipeline_compat", - ] = "pipeline_compat" + "pipeline_adapter", + ] = "pipeline_adapter" """Source of the trigger. - platform: Direct platform event @@ -30,7 +30,7 @@ class AgentTrigger(pydantic.BaseModel): - api: API trigger - scheduler: Scheduled trigger - system: System event - - pipeline_compat: Pipeline compatibility adapter + - pipeline_adapter: Pipeline adapter """ timestamp: int | None = None diff --git a/tests/api/entities/builtin/agent_runner/test_context_result.py b/tests/api/entities/builtin/agent_runner/test_context_result.py index b0ad6446..c69992e5 100644 --- a/tests/api/entities/builtin/agent_runner/test_context_result.py +++ b/tests/api/entities/builtin/agent_runner/test_context_result.py @@ -7,7 +7,7 @@ from langbot_plugin.api.entities.builtin.agent_runner.context import ( AgentRunContext, - CompatibilityContext, + AdapterContext, ) from langbot_plugin.api.entities.builtin.agent_runner.result import ( AgentRunResult, @@ -63,7 +63,7 @@ class TestAgentRunContextV1: def test_minimal_context_validate(self): """Test minimal required fields validation.""" - trigger = AgentTrigger(type="message.received", source="pipeline_compat") + trigger = AgentTrigger(type="message.received", source="pipeline_adapter") event = AgentEventContext( event_id="evt_1", event_type="message.received", @@ -150,21 +150,21 @@ def test_context_access_default(self): assert context_access.has_history_before is False assert context_access.inline_policy.mode == "current_event" - def test_compatibility_context(self): - """Test CompatibilityContext for legacy fields.""" - compat = CompatibilityContext( + def test_adapter_context(self): + """Test AdapterContext for Pipeline adapter fields.""" + adapter = AdapterContext( query_id=123, pipeline_uuid="pipe-123", max_round=10, ) - assert compat.query_id == 123 - assert compat.max_round == 10 + assert adapter.query_id == 123 + assert adapter.max_round == 10 def test_full_context_validate(self): """Test full context with all optional fields.""" trigger = AgentTrigger( - type="message.received", source="pipeline_compat", timestamp=1234567890 + type="message.received", source="pipeline_adapter", timestamp=1234567890 ) conversation = ConversationContext( conversation_id="conv_1", @@ -259,7 +259,7 @@ def test_context_model_validate_from_dict(self): """Test model_validate from dict (as LangBot will send).""" data = { "run_id": "run_dict", - "trigger": {"type": "message.received", "source": "pipeline_compat"}, + "trigger": {"type": "message.received", "source": "pipeline_adapter"}, "event": { "event_id": "evt_1", "event_type": "message.received", @@ -384,8 +384,8 @@ def test_artifact_created_with_new_fields(self): assert result.data["metadata"] == {"source": "generated"} assert result.data["content_base64"] == base64.b64encode(content).decode("utf-8") - def test_artifact_created_backward_compat_size(self): - """Test artifact.created backward compatibility: size -> size_bytes.""" + def test_artifact_created_size_alias(self): + """Test artifact.created size alias: size -> size_bytes.""" result = AgentRunResult.artifact_created( run_id="run_1", artifact_id="artifact_1",