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 0000000..116af87 --- /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/PHASE0_INTEGRATION_LOG.md b/docs/agent-runner-pluginization/PHASE0_INTEGRATION_LOG.md new file mode 100644 index 0000000..2d74354 --- /dev/null +++ b/docs/agent-runner-pluginization/PHASE0_INTEGRATION_LOG.md @@ -0,0 +1,125 @@ +# 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 二次权限校验 + +--- + +## 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 diff --git a/docs/agent-runner-pluginization/PROTOCOL_V1.md b/docs/agent-runner-pluginization/PROTOCOL_V1.md new file mode 100644 index 0000000..90f2cc2 --- /dev/null +++ b/docs/agent-runner-pluginization/PROTOCOL_V1.md @@ -0,0 +1,441 @@ +# 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 + 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 +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": { + "scope": "conversation", + "key": "external.conversation_id", + "value": "abc" + } +} +``` + +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 + +```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 0000000..47adab0 --- /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 e69de29..3069466 100644 --- a/src/langbot_plugin/api/definition/components/__init__.py +++ b/src/langbot_plugin/api/definition/components/__init__.py @@ -0,0 +1,15 @@ +"""Components for LangBot plugins.""" + +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.agent_runner.runner import AgentRunner + +__all__ = [ + "BaseComponent", + "Command", + "Tool", + "EventListener", + "AgentRunner", +] \ No newline at end of file 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 0000000..7a56be3 --- /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 0000000..09e69f6 --- /dev/null +++ b/src/langbot_plugin/api/definition/components/agent_runner/runner.py @@ -0,0 +1,171 @@ +"""Agent Runner component definition for Protocol v1.""" + +from __future__ import annotations + +import abc +from typing import Any, AsyncGenerator + +from langbot_plugin.api.definition.components.base import BaseComponent +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 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 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 import ( + AgentRunContext, + AgentRunResult, + AgentInput, + ) + from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk + + class MyAgentRunner(AgentRunner): + @classmethod + def get_capabilities(cls) -> AgentRunnerCapabilities: + return AgentRunnerCapabilities( + streaming=True, + tool_calling=True, + ) + + 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 + + async for chunk in api.invoke_llm_stream(model_uuid, messages): + yield AgentRunResult.message_delta(ctx.run_id, chunk) + + # Final message + final_message = Message(role="assistant", content="Hello world") + yield AgentRunResult.run_completed(ctx.run_id, message=final_message) + ``` + """ + + __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. + + 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: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: + """Run the agent to process user input. + + Args: + ctx: Agent run context containing: + - 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 (REQUIRED for Protocol v1) + - actor: Who triggered the event + - subject: What the event is about + - 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) + - adapter: Pipeline adapter / host adapter metadata + + Yields: + AgentRunResult: Progress and final result events: + - 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: + ```python + 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 - NOTE: ctx.run_id is REQUIRED + chunk = MessageChunk(role="assistant", content="Response") + yield AgentRunResult.message_delta(ctx.run_id, chunk) + + # 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 new file mode 100644 index 0000000..04b75bb --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/__init__.py @@ -0,0 +1,122 @@ +"""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.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, + ArtifactRef, +) +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.state import ( + AgentRunState, + VALID_STATE_SCOPES, +) +from langbot_plugin.api.entities.builtin.agent_runner.event import ( + ConversationContext, + 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, + AdapterContext, +) +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, +) +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, +) +from langbot_plugin.api.entities.builtin.agent_runner.artifact import ( + ArtifactMetadata, + ArtifactReadResult, +) + +__all__ = [ + # Manifest and policy + "AgentRunnerManifest", + "DynamicFormItemSchema", + "I18nObject", + "AgentRunnerCapabilities", + "AgentRunnerPermissions", + "AgentRunnerContextPolicy", + # Event and context + "AgentTrigger", + "AgentInput", + "ArtifactRef", + "AgentResources", + "ModelResource", + "ToolResource", + "KnowledgeBaseResource", + "FileResource", + "StorageResource", + "AgentRuntimeContext", + "AgentRunState", + "VALID_STATE_SCOPES", + "ConversationContext", + "AgentEventContext", + "ActorContext", + "SubjectContext", + "RawEventRef", + # Protocol v1 context access + "ContextAccess", + "InlineContextPolicy", + "ContextAPICapabilities", + "DeliveryContext", + "BootstrapContext", + "AdapterContext", + # Main context and result + "AgentRunContext", + "AgentRunResult", + "AgentRunResultType", + # Legacy (deprecated) + "AgentRunReturn", + "create_legacy_context", + # History and Event APIs + "TranscriptItem", + "HistoryPage", + "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 0000000..34e449d --- /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/entities/builtin/agent_runner/bootstrap.py b/src/langbot_plugin/api/entities/builtin/agent_runner/bootstrap.py new file mode 100644 index 0000000..e55c41f --- /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. + - Pipeline adapter max-round only affects adapter bootstrap, 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 new file mode 100644 index 0000000..5db9946 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/capabilities.py @@ -0,0 +1,40 @@ +"""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 = 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).""" + + interrupt: bool = False + """Runner supports cancel or interrupt operations.""" + + 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 new file mode 100644 index 0000000..e542241 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/context.py @@ -0,0 +1,128 @@ +"""Agent run context as defined in Protocol v1.""" + +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.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.state import AgentRunState +from langbot_plugin.api.entities.builtin.agent_runner.event import ( + ConversationContext, + AgentEventContext, + 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 AdapterContext(pydantic.BaseModel): + """Context for Pipeline adapter / host adapter metadata. + + This context holds adapter-specific fields for transition from Query/Pipeline. + 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 + """Pipeline adapter max-round (for reference only, should NOT be used by new runners).""" + + 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 adapter-specific fields.""" + + +class AgentRunContext(pydantic.BaseModel): + """Agent run context passed to AgentRunner.run(). + + 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) + - adapter holds Pipeline adapter / host adapter metadata + + 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 + """Unique identifier for this run.""" + + trigger: AgentTrigger + """Trigger information.""" + + event: AgentEventContext + """Event context (REQUIRED for Protocol v1).""" + + conversation: ConversationContext | None = None + """Conversation context.""" + + actor: ActorContext | None = None + """Actor context.""" + + subject: SubjectContext | None = None + """Subject context.""" + + input: AgentInput + """User input (current event input, not history).""" + + 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. + + 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.""" + + config: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Runner instance configuration (binding config from Host).""" + + bootstrap: BootstrapContext | None = None + """Optional bootstrap context (small convenience window, NOT full history).""" + + adapter: AdapterContext | None = None + """Adapter context for Pipeline adapter / host adapter metadata. + + 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 0000000..89d0584 --- /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 0000000..abab7bc --- /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 0000000..558ce1a --- /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 new file mode 100644 index 0000000..1f5e25e --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/event.py @@ -0,0 +1,111 @@ +"""Agent event, actor, subject contexts as defined in Protocol v1.""" + +from __future__ import annotations + +import typing +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. + """ + + conversation_id: str | None = None + """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).""" + + launcher_id: str | None = None + """Launcher ID.""" + + sender_id: str | None = None + """Sender ID.""" + + bot_id: str | None = None + """Bot UUID.""" + + workspace_id: str | None = None + """Workspace ID (for multi-tenant scenarios).""" + + # Pipeline adapter fields + session_id: str | None = None + """Pipeline session identifier (prefer conversation_id for stable identity).""" + + pipeline_uuid: str | None = None + """Pipeline UUID.""" + + +class AgentEventContext(pydantic.BaseModel): + """Event envelope for EBA (Event-Based Architecture) support. + + Protocol v1 is event-first: event is a required field in AgentRunContext. + """ + + event_id: str + """Unique event identifier.""" + + event_type: str + """Event type using stable protocol names (e.g., message.received).""" + + event_time: int | None = None + """Event timestamp (epoch seconds).""" + + source: str + """Event source (platform, webui, api, scheduler, system, pipeline_adapter).""" + + 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 + """Actor type (user, system, plugin).""" + + actor_id: str | None = None + """Actor ID.""" + + 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 + """Subject type (message, conversation, etc.).""" + + subject_id: str | None = None + """Subject ID.""" + + 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 0000000..8068397 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/input.py @@ -0,0 +1,65 @@ +"""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 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 + """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 reference (adapter field, not stable dependency).""" + + attachments: list[ArtifactRef] = pydantic.Field(default_factory=list) + """Artifact references for files/images/attachments.""" + + 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 0000000..7f7508c --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/legacy.py @@ -0,0 +1,308 @@ +"""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, 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 migration helpers). + Callers should pass the actual run_id from context if available. + """ + warnings.warn( + "AgentRunReturn is deprecated. Use AgentRunResult instead.", + DeprecationWarning, + stacklevel=2, + ) + + if self.type == "chunk": + if 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(run_id, chunk) + else: + return AgentRunResult.run_failed( + 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(run_id, message) + else: + return AgentRunResult.run_failed( + run_id, "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( + run_id=run_id, + tool_call_id=tc.id, + tool_name=tc.function.name, + parameters={"arguments": tc.function.arguments}, + ) + else: + 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", + ) + + +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, + ) + 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, + sdk_protocol_version="1", + query_id=query_id, + trace_id=None, + deadline_at=None, + 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=event, # REQUIRED + actor=None, + subject=None, + input=agent_input, + delivery=delivery, # REQUIRED + resources=resources, + context=ContextAccess(), # REQUIRED + state={}, + runtime=runtime, + config=extra_config, + bootstrap=bootstrap, # Historical messages go here + adapter=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 0000000..f612f25 --- /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 0000000..3193d91 --- /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 new file mode 100644 index 0000000..0e3c289 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/permissions.py @@ -0,0 +1,59 @@ +"""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["invoke", "stream", "rerank"]] = ( + pydantic.Field(default_factory=list) + ) + """Model operations allowed.""" + + tools: list[typing.Literal["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.""" + + 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.""" + + 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 0000000..9608d62 --- /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 0000000..84d17b2 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/result.py @@ -0,0 +1,312 @@ +"""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 +from langbot_plugin.api.entities.builtin.agent_runner.state import VALID_STATE_SCOPES, STATE_SCOPE_LITERAL + + +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" + ARTIFACT_CREATED = "artifact.created" + ACTION_REQUESTED = "action.requested" + RUN_COMPLETED = "run.completed" + RUN_FAILED = "run.failed" + + +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, + 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, + 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")}, + ) + + @classmethod + def tool_call_started( + cls, + run_id: str, + 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( + run_id=run_id, + type=AgentRunResultType.TOOL_CALL_STARTED, + data={ + "tool_call_id": tool_call_id, + "tool_name": tool_name, + "parameters": parameters, + }, + ) + + @classmethod + def tool_call_completed( + cls, + run_id: str, + 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( + run_id=run_id, + type=AgentRunResultType.TOOL_CALL_COMPLETED, + data={ + "tool_call_id": tool_call_id, + "tool_name": tool_name, + "result": result, + "error": error, + }, + ) + + @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, + *, + 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 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. + """ + # 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 + + 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=data, + ) + + @classmethod + def state_updated( + cls, + run_id: str, + key: str, + value: typing.Any, + scope: STATE_SCOPE_LITERAL = "conversation", + ) -> "AgentRunResult": + """Create a state.updated result. + + Runner requests host to persist a state change. + 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. + Defaults to "conversation" when omitted. + + 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( + run_id, + "external.conversation_id", + "abc123", + scope="conversation" + ) + + # Store user preference (backward compatible) + yield AgentRunResult.state_updated(run_id, "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( + run_id=run_id, + type=AgentRunResultType.STATE_UPDATED, + data={"scope": scope, "key": key, "value": value}, + ) + + @classmethod + def run_completed( + cls, + run_id: str, + 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(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, + ) -> "AgentRunResult": + """Create a run.failed result. + + LangBot returns user-friendly error message per pipeline error strategy. + """ + return cls( + run_id=run_id, + type=AgentRunResultType.RUN_FAILED, + data={ + "error": error, + "code": code or "runner.error", + "retryable": retryable, + }, + ) + + @classmethod + def action_requested( + cls, + run_id: str, + action: str, + 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, + "target": target, + "payload": payload, + }, + ) 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 0000000..b94895d --- /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 + """Pipeline query ID when the run enters through Pipeline adapter.""" + + trace_id: str | None = None + """Trace ID for observability.""" + + deadline_at: float | 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/state.py b/src/langbot_plugin/api/entities/builtin/agent_runner/state.py new file mode 100644 index 0000000..30feded --- /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/entities/builtin/agent_runner/transcript.py b/src/langbot_plugin/api/entities/builtin/agent_runner/transcript.py new file mode 100644 index 0000000..39c5a8e --- /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 new file mode 100644 index 0000000..f63e5ae --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/agent_runner/trigger.py @@ -0,0 +1,37 @@ +"""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'. Should match event.event_type or coarser.""" + + source: typing.Literal[ + "platform", + "webui", + "api", + "scheduler", + "system", + "pipeline_adapter", + ] = "pipeline_adapter" + """Source of the trigger. + + - platform: Direct platform event + - webui: WebUI debug chat + - api: API trigger + - scheduler: Scheduled trigger + - system: System event + - pipeline_adapter: Pipeline adapter + """ + + 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 69d1825..6b8e55d 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/api/proxies/__init__.py b/src/langbot_plugin/api/proxies/__init__.py index 0652c2f..80dfc75 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 0000000..05d5f3d --- /dev/null +++ b/src/langbot_plugin/api/proxies/agent_run_api.py @@ -0,0 +1,852 @@ +"""AgentRun API Proxy for AgentRunner components. + +This proxy provides a restricted API for AgentRunner execution, +with all capabilities explicitly authorized through ctx.resources. + +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 +import time +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.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): + """Raised when an API call is not authorized by ctx.resources.""" + + pass + + +class AgentRunAPIProxy: + """Restricted API proxy for AgentRunner execution. + + 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 + - 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 + - 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 + + Additional APIs (AgentRunner-specific): + - 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() + - list_tools() / list_knowledge_bases() / get_llm_models() + - vector_upsert() / vector_search() / invoke_embedding() + """ + + ctx: AgentRunContext + """Agent run context containing run_id, resources, and runtime info.""" + + _api: LangBotAPIProxy + """Unrestricted API proxy for delegation (composition).""" + + # 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): + self.ctx = ctx + 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) + 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 + + 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]: + """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 (delegated with validation) ================= + + 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, + remove_think: bool | None = None, + ) -> provider_message.Message: + """Invoke an LLM model with permission validation.""" + self._validate_model_access(llm_model_uuid) + effective_timeout = self._bounded_timeout(default=120.0, requested=timeout) + 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, + payload, + effective_timeout, + ) + return provider_message.Message.model_validate(resp["message"]) + + 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] = {}, + remove_think: bool | None = None, + ): + """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": 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, + effective_timeout, + ): + yield provider_message.MessageChunk.model_validate(chunk_data["chunk"]) + + # ================= Tool APIs (delegated with validation) ================= + + 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) + 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, + }, + timeout, + ) + return resp.get("tool", resp) + + 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) + timeout = self._bounded_timeout(default=180.0) + 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, + }, + timeout, + ) + return resp.get("result", resp.get("tool_response", resp)) + + # ================= Knowledge Base API (delegated with 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.""" + 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, + { + "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, + ) + )["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.plugin_runtime_handler.call_action( + PluginToRuntimeAction.SET_PLUGIN_STORAGE, + { + "run_id": self.run_id, + "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: + """Get a plugin storage value with permission validation.""" + self._validate_plugin_storage_access() + resp = ( + await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.GET_PLUGIN_STORAGE, + { + "run_id": self.run_id, + "key": key, + }, + self._bounded_timeout(default=15.0), + ) + )["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.plugin_runtime_handler.call_action( + PluginToRuntimeAction.GET_PLUGIN_STORAGE_KEYS, + { + "run_id": self.run_id, + }, + self._bounded_timeout(default=15.0), + ) + )["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.plugin_runtime_handler.call_action( + PluginToRuntimeAction.DELETE_PLUGIN_STORAGE, + { + "run_id": self.run_id, + "key": key, + }, + self._bounded_timeout(default=15.0), + ) + + 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.plugin_runtime_handler.call_action( + PluginToRuntimeAction.SET_WORKSPACE_STORAGE, + { + "run_id": self.run_id, + "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: + """Get a workspace storage value with permission validation.""" + self._validate_workspace_storage_access() + resp = ( + await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.GET_WORKSPACE_STORAGE, + { + "run_id": self.run_id, + "key": key, + }, + self._bounded_timeout(default=15.0), + ) + )["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.plugin_runtime_handler.call_action( + PluginToRuntimeAction.GET_WORKSPACE_STORAGE_KEYS, + { + "run_id": self.run_id, + }, + self._bounded_timeout(default=15.0), + ) + )["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.plugin_runtime_handler.call_action( + PluginToRuntimeAction.DELETE_WORKSPACE_STORAGE, + { + "run_id": self.run_id, + "key": key, + }, + self._bounded_timeout(default=15.0), + ) + + # ================= File API (delegated with validation) ================= + + async def get_file(self, file_key: str) -> bytes: + """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) + resp = ( + await self._api.plugin_runtime_handler.call_action( + PluginToRuntimeAction.GET_CONFIG_FILE, + { + "run_id": self.run_id, + "file_key": file_key, + }, + self._bounded_timeout(default=15.0), + ) + )["file_base64"] + return base64.b64decode(resp) + + # ================= Version API (no authorization needed, delegated) ================= + + async def get_langbot_version(self) -> str: + """Get the LangBot version (no authorization needed).""" + return await self._api.get_langbot_version() + + # ================= Rerank API (AgentRunner-specific, not in LangBotAPIProxy) ================= + + async def invoke_rerank( + self, + rerank_model_uuid: str, + 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. + + 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 + extra_args: Optional provider-specific options + 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}, + # ... + # ] + """ + 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, + { + "run_id": self.run_id, + "rerank_model_uuid": rerank_model_uuid, + "query": query, + "documents": documents, + "top_k": top_k, + "extra_args": extra_args or {}, + }, + 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 + + # ================= 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) + + # ================= 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/api/proxies/langbot_api.py b/src/langbot_plugin/api/proxies/langbot_api.py index 572a1e4..43f1126 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( @@ -428,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/assets/templates/components/agent_runner/__init__.py b/src/langbot_plugin/assets/templates/components/agent_runner/__init__.py new file mode 100644 index 0000000..e69de29 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 0000000..dcab3ad --- /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 0000000..7596573 --- /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 3d2a0eb..ab01ea8 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 f138b23..c63f5d3 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 6060ce1..62bcf35 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,52 @@ 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( + run_id=run_context.run_id, + 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.""" @@ -313,6 +360,68 @@ 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 _iter_runner_results_with_deadline( + runner_instance, + 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 d34c0a6..91ee832 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" @@ -61,11 +61,12 @@ 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" 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" @@ -78,6 +79,22 @@ 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" + + """Agent Artifact APIs (run-scoped, requires run_id authorization)""" + 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.""" @@ -92,6 +109,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" @@ -122,9 +142,13 @@ class LangBotToRuntimeAction(ActionType): LIST_COMMANDS = "list_commands" EXECUTE_COMMAND = "execute_command" - # RAG actions + # KnowledgeEngine retrieval action RETRIEVE_KNOWLEDGE = "retrieve_knowledge" + # AgentRunner actions + LIST_AGENT_RUNNERS = "list_agent_runners" + RUN_AGENT = "run_agent" + # 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 942373b..25c262c 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}"} @@ -251,8 +257,30 @@ 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) + @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"] diff --git a/src/langbot_plugin/runtime/io/handlers/plugin.py b/src/langbot_plugin/runtime/io/handlers/plugin.py index fbcce3a..4e36ed7 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, { @@ -221,6 +237,29 @@ 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 + + # 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, + { + **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( @@ -281,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, ) @@ -313,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, @@ -334,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, @@ -367,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"] = ( @@ -374,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, { @@ -386,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"] = ( @@ -393,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, { @@ -407,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"] = ( @@ -414,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, { @@ -426,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"] = ( @@ -433,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, { @@ -446,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, { @@ -459,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, { @@ -474,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, { @@ -489,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, { @@ -500,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) @@ -525,29 +621,48 @@ 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: - 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: @@ -560,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 57bb927..1bf5285 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/src/langbot_plugin/runtime/plugin/mgr.py b/src/langbot_plugin/runtime/plugin/mgr.py index ed87484..eeaf837 100644 --- a/src/langbot_plugin/runtime/plugin/mgr.py +++ b/src/langbot_plugin/runtime/plugin/mgr.py @@ -31,7 +31,9 @@ from langbot_plugin.api.definition.components.parser.parser import Parser from langbot_plugin.entities.io.actions.enums import ( RuntimeToLangBotAction, + RuntimeToPluginAction, ) +from langbot_plugin.entities.io.errors import ActionCallTimeoutError from langbot_plugin.api.entities.builtin.command.context import ( ExecuteContext, CommandReturn, @@ -42,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.""" @@ -427,7 +471,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,6 +848,174 @@ async def execute_command( break + # 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. + + 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]] = [] + + 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__: + # 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.manifest, # raw manifest dict + "protocol_version": protocol_version, + "capabilities": capabilities.model_dump(), + "permissions": permissions.model_dump(), + "config": config_schema, + } + ) + + 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 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, + ) + + # 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: + if ( + plugin.manifest.metadata.author == plugin_author + and plugin.manifest.metadata.name == plugin_name + ): + target_plugin = plugin + break + + 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") + return + + # Find the component (supports multiple runners per plugin) + 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 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") + return + + # 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") + return + + # Forward RUN_AGENT action to the plugin process and stream results + try: + gen = target_plugin._runtime_plugin_handler.call_action_generator( + RuntimeToPluginAction.RUN_AGENT, + { + "runner_name": runner_name, + "context": context, + }, + timeout=_runner_action_timeout(context), + ) + + # call_action_generator yields response.data directly on success, + # or raises ActionCallError on failure + 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( + run_id=run_id, + error="Agent runner timed out", + code="runner.timeout", + retryable=True, + ).model_dump(mode="json") + except Exception as e: + 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") + async def retrieve_knowledge( self, plugin_author: str, diff --git a/tests/api/definition/components/test_imports.py b/tests/api/definition/components/test_imports.py new file mode 100644 index 0000000..9032c30 --- /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_artifact_entities.py b/tests/api/entities/builtin/agent_runner/test_artifact_entities.py new file mode 100644 index 0000000..73c02c2 --- /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/entities/builtin/agent_runner/test_context_result.py b/tests/api/entities/builtin/agent_runner/test_context_result.py new file mode 100644 index 0000000..c69992e --- /dev/null +++ b/tests/api/entities/builtin/agent_runner/test_context_result.py @@ -0,0 +1,773 @@ +"""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, + AdapterContext, +) +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.state import ( + AgentRunState, + VALID_STATE_SCOPES, +) +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.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, + 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_adapter") + 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, + ) + + assert ctx.run_id == "run_123" + assert ctx.trigger.type == "message.received" + assert ctx.input.text == "Hello" + assert ctx.bootstrap is None # Optional, not required + assert ctx.config == {} + 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") + + # 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_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_123", + trigger=trigger, + event=event, + input=input, + delivery=delivery, + resources=resources, + runtime=runtime, + bootstrap=bootstrap, + ) + + # messages are in bootstrap + assert ctx.bootstrap is not None + assert len(ctx.bootstrap.messages) == 1 + + def test_context_access_default(self): + """Test ContextAccess default values.""" + context_access = ContextAccess() + + assert context_access.conversation_id is None + assert context_access.has_history_before is False + assert context_access.inline_policy.mode == "current_event" + + def test_adapter_context(self): + """Test AdapterContext for Pipeline adapter fields.""" + adapter = AdapterContext( + query_id=123, + pipeline_uuid="pipe-123", + 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_adapter", timestamp=1234567890 + ) + conversation = ConversationContext( + conversation_id="conv_1", + thread_id="thread_1", + launcher_type="person", + launcher_id="12345", + sender_id="user_1", + bot_id="bot_123", + workspace_id="ws_1", + ) + event = AgentEventContext( + event_id="evt_1", + event_type="message.received", + event_time=1234567890, + source="platform", + ) + actor = ActorContext( + actor_type="user", + actor_id="user_1", + actor_name="Test User", + ) + subject = SubjectContext( + subject_type="message", + subject_id="msg_1", + ) + 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?")], + ) + 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", + 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", + trigger=trigger, + conversation=conversation, + event=event, + actor=actor, + subject=subject, + input=input, + 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.bootstrap is not None + assert len(ctx.bootstrap.messages) == 2 + assert ctx.config["model"] == "gpt-4" + 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, 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_adapter"}, + "event": { + "event_id": "evt_1", + "event_type": "message.received", + "source": "platform", + }, + "input": {"text": "Hello from dict"}, + "delivery": {"surface": "platform"}, + "resources": {}, + "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: + """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.""" + + def test_message_delta_validate(self): + """Test message.delta result.""" + chunk = MessageChunk(role="assistant", content="Hello") + 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" + + def test_message_completed_validate(self): + """Test message.completed result.""" + message = Message(role="assistant", content="Complete response") + 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_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_size_alias(self): + """Test artifact.created size alias: 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( + 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" + assert result.data["parameters"]["city"] == "Tokyo" + + 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" + assert result.data["value"] == "abc123" + + 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" + 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( + run_id="run_1", + 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( + run_id="run_1", + 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") + 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(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 + + 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" + assert result.data["retryable"] is True + + def test_run_failed_default_code(self): + """Test run.failed with default code.""" + 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", + 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("run_1", 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 capabilities defaults.""" + caps = AgentRunnerCapabilities() + assert not caps.streaming + assert not caps.tool_calling + assert not caps.knowledge_retrieval + assert not caps.multimodal_input + # 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.""" + perms = AgentRunnerPermissions() + 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 == [] + + 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=["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 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 0000000..3c84c9c --- /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/__init__.py b/tests/api/proxies/__init__.py new file mode 100644 index 0000000..a6ad11a --- /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 0000000..0244fbc --- /dev/null +++ b/tests/api/proxies/test_agent_run_api_proxy.py @@ -0,0 +1,907 @@ +"""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 time +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from langbot_plugin.api.proxies.agent_run_api import AgentRunAPIProxy, PermissionDeniedError +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction +from langbot_plugin.api.entities.builtin.provider.message import Message +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 +from langbot_plugin.api.entities.builtin.agent_runner.event import AgentEventContext +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + +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, + deadline_at: float | 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'), + 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 [])], + 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_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 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.""" + 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')] + 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) + + 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 + 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 + + @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.""" + + 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_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.""" + 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 + + +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/api/proxies/test_artifact_api_proxy.py b/tests/api/proxies/test_artifact_api_proxy.py new file mode 100644 index 0000000..cb08a48 --- /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" 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 0000000..47c8757 --- /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 new file mode 100644 index 0000000..43c1854 --- /dev/null +++ b/tests/runtime/plugin/test_mgr_agent_runner.py @@ -0,0 +1,552 @@ +"""Tests for PluginManager AgentRunner methods (Protocol v1).""" + +from __future__ import annotations + +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 +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 + + +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( + run_id=ctx.run_id, + message=Message(role="assistant", content=f"Echo: {ctx.input.to_text()}"), + ) + yield AgentRunResult.run_completed(run_id=ctx.run_id, 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(run_id=ctx.run_id, delta=chunk) + yield AgentRunResult.run_completed(run_id=ctx.run_id, 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(run_id=ctx.run_id, delta=chunk) + 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(run_id=ctx.run_id, finish_reason="stop") + + +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 resp # Yield response data directly (matches real call_action_generator) + return + # No matching responses found + 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 + 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"), + event=AgentEventContext( + event_id="test_event", + event_type="message.received", + source="test", + ), + input=AgentInput(text="Hello"), + delivery=DeliveryContext(surface="test"), + 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" + + @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 diff --git a/tests/runtime/test_pull_api_handlers.py b/tests/runtime/test_pull_api_handlers.py new file mode 100644 index 0000000..2d1f840 --- /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"