diff --git a/docs.json b/docs.json index 11f375f02..4ef85f881 100644 --- a/docs.json +++ b/docs.json @@ -701,6 +701,8 @@ "group": "Plugin SDK API", "pages": [ "en/plugin/dev/apis/common", + "en/plugin/dev/apis/agent-run", + "en/plugin/dev/apis/platform", "en/plugin/dev/apis/pipeline-events", "en/plugin/dev/apis/messages", "en/plugin/dev/apis/tech-details" @@ -1005,6 +1007,8 @@ "group": "插件 SDK API", "pages": [ "zh/plugin/dev/apis/common", + "zh/plugin/dev/apis/agent-run", + "zh/plugin/dev/apis/platform", "zh/plugin/dev/apis/pipeline-events", "zh/plugin/dev/apis/messages", "zh/plugin/dev/apis/tech-details" @@ -1304,6 +1308,8 @@ "group": "プラグイン SDK API", "pages": [ "ja/plugin/dev/apis/common", + "ja/plugin/dev/apis/agent-run", + "ja/plugin/dev/apis/platform", "ja/plugin/dev/apis/pipeline-events", "ja/plugin/dev/apis/messages", "ja/plugin/dev/apis/tech-details" diff --git a/en/plugin/dev/apis/agent-run.mdx b/en/plugin/dev/apis/agent-run.mdx new file mode 100644 index 000000000..ea59012e1 --- /dev/null +++ b/en/plugin/dev/apis/agent-run.mdx @@ -0,0 +1,182 @@ +--- +title: "Run Context APIs" +--- + +AgentRunner and EventProcessor use run-scoped APIs to access authorized models, tools, knowledge bases, and context. Neither component exposes `self.plugin`. + +## Access + +```python +# Inside an EventProcessor handler +api = ctx.api + +# Inside AgentRunner.run(ctx) +api = self.get_run_api(ctx) +``` + +In the examples below, `api` refers to the current run API. Use `await` for the methods below, except synchronous `get_allowed_*` methods and streaming context/iterator APIs. Do not retain or reuse it across runs. + +### Configuration and permissions + +`ctx.config` contains the processor or Runner instance configuration; `self.get_plugin_config()` reads plugin-level configuration. Manifest `spec.permissions` declares requested permissions; actual resources also depend on user configuration and the current event. + +```yaml +spec: + capabilities: + tool_calling: true + permissions: + tools: [detail, call] +``` + +This is the minimum declaration for replies and tool calls. Add other entries to `spec.permissions` as needed: + +| `spec.permissions` | Operations | +| --- | --- | +| `models` | `invoke`, `stream`, `rerank`, `count_tokens` | +| `tools` | `detail`, `call` | +| `knowledge_bases` | `list`, `retrieve` | +| `history` | `page`, `search` | +| `events` | `get`, `page` | +| `storage` | `plugin`, `workspace` | +| `interactions` | `request` | + +Inspect the resources actually granted: + +```python +models = api.get_allowed_models() # Each item has model_id and operations +tools = api.get_allowed_tools() # Each item has tool_name and parameters +knowledge_bases = api.get_allowed_knowledge_bases() # Each item has kb_id +``` + +Check context capabilities through `api.ctx.context.available_apis`. Do not call an API whose flag is `False`; a manifest permission alone does not enable it. + +## Replies and logs + +| EventProcessor method | Purpose | +| --- | --- | +| `await ctx.reply(text)` | Send text to the current event conversation; return the action result. | +| `await ctx.log(text, level="info")` | Record a log at `debug`, `info`, `warning`, or `error` level. | +| `ctx.reply_stream()` | Update one reply through an async context manager. | + +`ctx.reply(text)` is equivalent to `await api.call_tool("event_reply", {"text": text})`. The event must have a reply target and grant `event_reply`. Logs and text returned by an independent Agent are not automatically sent to the platform. + +### Streaming replies + +```python +async with api.reply_stream() as reply: + await reply.update("Processing…") + await reply.update("Processing complete.") +result = reply.result +``` + +EventProcessor can use `ctx.reply_stream()` to also record the action start and result. + +`update(text)` takes the **complete text so far**, not a delta, and returns the action result. Normal exit completes the reply. Message events with platform streaming support enabled update the platform message; other replyable events buffer text and send once on successful exit. Errors or cancellation discard buffered partial text; already displayed content is not guaranteed to be retracted. Debug mode simulates platform delivery. + +This requires `available_apis.reply_stream` and the `event_reply` permission. Model streaming and platform streaming are separate APIs; the plugin explicitly connects them. + +## Models, tools, and knowledge bases + +See [Common APIs](/en/plugin/dev/apis/common) for model parameters and usage objects. The run API also provides: + +| Method | Result or purpose | +| --- | --- | +| `invoke_llm(llm_model_uuid, messages, ...)` | `Message` | +| `invoke_llm_with_usage(llm_model_uuid, messages, ...)` | `LLMInvokeResult` | +| `invoke_llm_stream(llm_model_uuid, messages, ...)` | Async iterator of `MessageChunk` | +| `invoke_llm_stream_events(llm_model_uuid, messages, ...)` | Async iterator of `LLMStreamEvent` | +| `count_tokens(llm_model_uuid, messages, funcs=None, extra_args=None, timeout=None)` | Input token count, `int` | +| `invoke_rerank(rerank_model_uuid, query, documents, top_k=None, extra_args=None, timeout=30.0)` | List with `index` and `relevance_score` | +| `get_tool_detail(tool_name)` | Tool description and parameter schema, `dict` | +| `call_tool(tool_name, parameters)` | Tool result, `dict`; no `session` or `query_id` | +| `retrieve_knowledge(kb_id, query_text, top_k=5, filters=None)` | Retrieval result list | + +All four model invocation methods also accept `remove_think=None`; non-streaming methods accept `timeout=None`. Timeouts are bounded by the remaining run time. Resource lists may be empty; do not assume a first model or knowledge base exists. + +```python +allowed = {tool.tool_name for tool in api.get_allowed_tools()} +if "event_get_actor" in allowed: + actor = await api.call_tool("event_get_actor", {}) +``` + +LangBot fixes targets for `event_*` tools; `platform_*` tools accept targets according to their schema. Use names and parameters from the granted list; pass `{}` to tools with no parameters. + +## History and events + +The current input does not include full history. Pull records within the current run scope as needed; passing another conversation ID does not expand access. + +| Method | Returns | +| --- | --- | +| `get_prompt()` | Effective prompt list; capability flag: `prompt_get` | +| `history_page(conversation_id=None, before_cursor=None, after_cursor=None, limit=50, direction="backward", include_attachments=False)` | `HistoryPage` | +| `history_search(query, filters=None, top_k=10)` | `HistorySearchResult` | +| `event_get(event_id)` | `AgentEventRecord` | +| `event_page(conversation_id=None, event_types=None, before_cursor=None, limit=50)` | `EventPage` | +| `steering_pull(mode="all", limit=None)` | `SteeringPullResult`; consume queued follow-up inputs | + +```python +if api.ctx.context.available_apis.history_page: + page = await api.history_page(limit=20) + for item in page.items: + print(item.model_dump()) + if page.has_more and page.prev_cursor: + older = await api.history_page(before_cursor=page.prev_cursor, limit=20) +``` + +Page results are SDK objects: use `.items`, `.has_more`, `.prev_cursor`, and `.next_cursor`, not dictionary indexing. Use `before_cursor` for older records, and `after_cursor` with `direction="forward"` for newer records. History search may only provide text matching. `steering_pull` accepts `all`, `one`, or `one-at-a-time` for Runners supporting follow-up inputs. + +## State and storage + +| Method | Returns | +| --- | --- | +| `state_get(scope, key)` | `{"value": ...}` | +| `state_set(scope, key, value)` | `{"success": ...}` | +| `state_delete(scope, key)` | `{"success": ...}` | +| `state_list(scope, prefix=None, limit=100)` | `{"keys": [...], "has_more": ...}` | + +State requires `available_apis.state` and a Host-enabled scope: `conversation`, `actor`, `subject`, or `runner`. Values must be JSON-serializable; use a key prefix such as `external.`. LangBot persists state; these methods return dictionaries. + +```python +if api.ctx.context.available_apis.state: + await api.state_set("conversation", "external.last_topic", "setup") + value = await api.state_get("conversation", "external.last_topic") +``` + +Plugin and workspace storage provide `set_plugin_storage`, `get_plugin_storage`, `get_plugin_storage_keys`, `delete_plugin_storage`, and the corresponding `*_workspace_storage*` methods. Parameters match [Common APIs](/en/plugin/dev/apis/common); values are `bytes`. Access requires `api.ctx.resources.storage.plugin_storage` or `workspace_storage` respectively. + +## Run records + +The following methods require the matching `available_apis` flag and only access runs within the authorized scope. + +| Method | Returns | +| --- | --- | +| `run_get(run_id=None)` | `AgentRun` | +| `run_list(conversation_id=None, statuses=None, before_cursor=None, limit=50)` | `RunPage` | +| `run_events_page(run_id=None, before_cursor=None, after_cursor=None, limit=50, direction="forward")` | `RunEventPage` | +| `run_cancel(run_id=None, reason=None)` | `AgentRun` | +| `run_append_result(result)` | `AgentRunEvent` | +| `run_finalize(run_id=None, status=None, reason=None)` | `AgentRun` | + +`run_id=None` selects the current run. EventProcessor handlers finish automatically when they return and usually do not need to append or finalize records manually. AgentRunner reports results with `yield AgentRunResult`; do not also append the same result through `run_append_result`. + +## Structured interactions (AgentRunner) + +To request confirmation or form input, yield `AgentRunResult.interaction_requested(ctx.run_id, request)`. `request` accepts an `InteractionRequest` object or dictionary. Required fields are `interaction_id`, `title`, and `fallback_text`; optional fields include `kind` (`form`, `confirmation`, `choice`), `fields`, `actions`, and `expires_at`. + +This requires `spec.capabilities.interactions: true`, `spec.permissions.interactions: [request]`, and Host authorization. Inspect `ctx.delivery.interactions` for surface support; submissions return as `interaction.submitted` events. This is a Runner result event, not a method on `ctx.api`, and is not directly exposed by ordinary EventProcessor handlers. + +## Error handling + +```python +from langbot_plugin.api.proxies.agent_run import PermissionDeniedError +from langbot_plugin.api.entities.builtin.agent_runner.errors import AgentAPIException + +try: + result = await api.call_tool("event_get_actor", {}) +except PermissionDeniedError as exc: + await ctx.log(str(exc), level="warning") +except AgentAPIException as exc: + await ctx.log(f"{exc.error.code}: {exc.error.message}", level="error") +``` + +This example runs inside an EventProcessor. `PermissionDeniedError` means a resource, operation, or capability is not granted. `AgentAPIException.error` contains `code`, `message`, `retryable`, and `details`. A timed-out send may already have succeeded; do not resend solely because `retryable` is true. diff --git a/en/plugin/dev/apis/common.mdx b/en/plugin/dev/apis/common.mdx index c2d247131..9df6a2078 100644 --- a/en/plugin/dev/apis/common.mdx +++ b/en/plugin/dev/apis/common.mdx @@ -4,6 +4,8 @@ title: "Plugin Common APIs" LangBot provides a series of APIs for plugins to operate various LangBot modules and control message contexts. +Regular components such as `Command`, `EventListener`, and `Tool` use these APIs through `self.plugin`. `AgentRunner` and `EventProcessor` use [Run Context APIs](/en/plugin/dev/apis/agent-run), without `self.plugin`. See [Platform APIs](/en/plugin/dev/apis/platform) for platform queries and specific actions. + ## Request API Operations for the currently processing user request (message). Only available in `EventListener` and `Command` components. Access methods: @@ -105,12 +107,20 @@ knowledge_bases = await event_context.list_pipeline_knowledge_bases() Only returns knowledge bases bound in the `Local Agent` configuration of the pipeline corresponding to the current request. If the current pipeline has no knowledge bases configured, or does not use `Local Agent`, an empty list is returned. +### Start a new conversation + +`await event_context.create_new_conversation()` clears the conversation currently used by the Pipeline session, so subsequent processing starts a new one. It returns an empty dictionary and does not delete platform chat history. + +### Retrieve from Pipeline knowledge bases + +`await event_context.retrieve_knowledge(kb_id, query_text, top_k=5, filters=None)` returns retrieval results. `kb_id` must come from `list_pipeline_knowledge_bases()`; this request API cannot access knowledge bases outside the Pipeline configuration. + ## LangBot API These APIs can be called in any plugin component. Access methods: - In the plugin root directory `main.py`: Internal methods of the `self` object, these APIs are all provided by the plugin class parent class `BasePlugin`. -- In any plugin component class: Internal methods of the `self.plugin` object. +- In regular plugin component classes: Internal methods of the `self.plugin` object. ### Get Plugin Configuration @@ -224,6 +234,7 @@ async def invoke_llm( messages: list[provider_message.Message], funcs: list[resource_tool.LLMTool] = [], extra_args: dict[str, Any] = {}, + timeout: float | None = None, ) -> provider_message.Message: """Invoke an LLM model""" @@ -236,6 +247,45 @@ llm_message = await self.plugin.invoke_llm( ) ``` + +### Model streaming and usage + +| Method | Returns | +| --- | --- | +| `invoke_llm_with_usage(llm_model_uuid, messages, funcs=[], extra_args={}, timeout=None)` | `LLMInvokeResult` | +| `invoke_llm_stream(llm_model_uuid, messages, funcs=[], extra_args={})` | Async iterator of `MessageChunk` | +| `invoke_llm_stream_events(llm_model_uuid, messages, funcs=[], extra_args={})` | Async iterator of `LLMStreamEvent` | + + +Parameters match `invoke_llm`. `timeout` is in seconds; non-streaming calls default to 120 seconds. Use `invoke_llm_with_usage` when token usage is needed: + +```python +from langbot_plugin.api.entities.builtin.provider.message import Message + +result = await self.plugin.invoke_llm_with_usage( + llm_model_uuid=model_uuid, + messages=[Message(role="user", content="Hello!")], +) +print(result.message.content) +if result.usage is not None: + print(result.usage.total_tokens) +``` + +`usage` may be `None`; `prompt_tokens`, `completion_tokens`, and `total_tokens` may also be absent. Missing values do not mean zero usage. Additional provider fields, such as cache usage, are preserved. + +```python +async for event in self.plugin.invoke_llm_stream_events( + llm_model_uuid=model_uuid, + messages=[Message(role="user", content="Hello!")], +): + if event.chunk is not None: + print(event.chunk.content) + if event.usage is not None: + print(event.usage.model_dump()) +``` + +`LLMStreamEvent` has optional `chunk` and `usage`; the final event may contain usage without text. `invoke_llm_stream` yields only message/tool-call chunks. `MessageChunk.content` is the current chunk; `all_content`, when supplied, is accumulated text. Replace with accumulated text and append only deltas; an `is_final` chunk may still carry text. Model streaming does not send platform messages; see [Run Context APIs](/en/plugin/dev/apis/agent-run) for explicit streaming replies. + ### List Available Parsers List Parser plugins currently available on the host, optionally filtered by MIME type. @@ -428,7 +478,7 @@ Use this in conjunction with configuration fields of type [``file or array[file` ## Knowledge Base API -These APIs are accessible via `self.plugin` in any component, allowing you to list and retrieve from all knowledge bases in the LangBot instance without pipeline restrictions. +These APIs are accessible via `self.plugin` in regular components, allowing you to list and retrieve from all knowledge bases in the LangBot instance without pipeline restrictions. ### List All Knowledge Bases @@ -486,7 +536,7 @@ for entry in results: ``` -These APIs do not require a `query_id` and can be used in any component (including Tool components). They can access all knowledge bases without being restricted to the current pipeline's configuration. +These APIs do not require a `query_id` and can be used in regular components (including Tool components). They can access all knowledge bases without being restricted to the current pipeline's configuration. ## RAG API @@ -676,3 +726,81 @@ async def get_knowledge_file_stream(self, storage_path: str) -> bytes: # Usage example file_bytes = await self.plugin.get_knowledge_file_stream(context.file_object.storage_path) ``` + + +## Additional resource APIs + +These methods are also called through `self.plugin`. + +### Invoke a rerank model + +```python +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 = 60.0, +) -> list[dict[str, Any]]: + ... + +scores = await self.plugin.invoke_rerank( + rerank_model_uuid=rerank_model_uuid, + query="How do I configure a bot?", + documents=["Bot configuration guide", "Plugin publishing guide"], + top_k=1, +) +``` + +Results usually contain the original document `index` and `relevance_score`. + +### Invoke a parser + +```python +async def invoke_parser( + self, plugin_author: str, plugin_name: str, storage_path: str, + mime_type: str, filename: str, metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + ... + +parsers = await self.plugin.list_parsers(mime_type="application/pdf") +if parsers: + parser = parsers[0] + parsed = await self.plugin.invoke_parser( + plugin_author=parser["plugin_author"], + plugin_name=parser["plugin_name"], + storage_path=storage_path, + mime_type="application/pdf", + filename="guide.pdf", + ) +``` + +`storage_path` is a Host storage path, such as `context.file_object.storage_path` during knowledge ingestion, not a plugin-local path. The result dictionary contains `text`, `sections`, and `metadata`. + +### List vector records + +```python +async def vector_list( + self, collection_id: str, filters: dict[str, Any] | None = None, + limit: int = 20, offset: int = 0, +) -> dict[str, Any]: + ... + +page = await self.plugin.vector_list( + collection_id=collection_id, + filters={"file_id": "doc_001"}, + limit=20, + offset=0, +) +for item in page["items"]: + print(item["id"], item.get("metadata")) +``` + +Returns `items` and `total`. Items contain `id`, `document`, and `metadata`; `total` is a best-effort matching count from the storage backend. Filters use the vector API format above. + +### List plugins and commands + +```python +manifests = await self.plugin.list_plugins_manifest() +commands = await self.plugin.list_commands() +``` + +Both methods take no arguments and return the Host-provided plugin manifest list and command list respectively. diff --git a/en/plugin/dev/apis/messages.mdx b/en/plugin/dev/apis/messages.mdx index eadf571c6..eea097b8d 100644 --- a/en/plugin/dev/apis/messages.mdx +++ b/en/plugin/dev/apis/messages.mdx @@ -58,34 +58,8 @@ When creating components, named parameters must be used. For specific usage, you ## Other Entities -In addition to the above entities, there are also entities like `Friend` and `Group`. These entities are defined in `langbot_plugin.api.entities.platform.entities`. +In addition to the above entities, there are also entities like `Friend` and `Group`. These entities are defined in `langbot_plugin.api.entities.builtin.platform.entities`. -## Accessing Underlying Platform APIs +## Platform APIs - - Not applicable for 4.x. - - -To abstract away platform differences, LangBot provides an abstraction layer above messaging platform APIs, which includes the content described above and various platform adapters in the `pkg/platform/sources` directory. However, due to significant platform differences, the entity and API encapsulation in the abstraction layer is not complete. If your plugin needs to access platform-specific functionality, you can access underlying APIs according to the following instructions. - - - ```python - # For example in a plugin - @handler(GroupMessageReceived) - async def _(self, ctx: EventContext): - # Get adapter object - adapter = ctx.query.adapter - ``` - - -| Platform | Adapter | Access Method | Description | -| ------------------------- | --------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| OneBot v11 | aiocqhttp | adapter.bot | bot object corresponds to [aiocqhttp](https://github.com/nonebot/aiocqhttp) CQHttp object, please refer to aiocqhttp documentation for detailed usage | -| QQ Official API | qqofficial | adapter.bot | bot object is the SDK object under libs/qq_official_api, for detailed usage please refer to the `send_group_text_msg` method in `QQOfficialClient` in libs/qq_official_api/api.py for HTTP request methods, please refer to QQ Official API documentation for specific interface documentation | -| WeCom (Enterprise WeChat) | wecom | adapter.bot | Refer to the `send_image` method in `WecomClient` in libs/wecom_api/api.py for HTTP request methods, please refer to WeCom API documentation for specific interface documentation | -| WeChat Official Account | officialaccount | - | Please refer to WeChat Official Account API documentation for interface documentation, credential information can be obtained from adapter.bot | -| Lark | lark | adapter.api_client | Lark SDK's API Client object, please refer to [oapi-sdk-python](https://github.com/larksuite/oapi-sdk-python) for details | -| DingTalk | dingtalk | - | Please refer to DingTalk API documentation, credential information can be obtained from adapter.bot | -| Discord | discord | adapter.bot | bot object is [Rapptz/discord.py](https://github.com/Rapptz/discord.py) Discord object, please refer to documentation for detailed usage | -| Telegram | telegram | adapter.bot | bot object is [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) Telegram object, please refer to documentation for detailed usage | -| Slack | slack | adapter.bot | bot object in libs/slack_api/api.py, please refer to its code and Slack SDK for detailed usage | \ No newline at end of file +Use [Platform APIs](/en/plugin/dev/apis/platform) to query users, manage groups, and call platform-specific actions. EventProcessor and AgentRunner call authorized platform tools through [Run Context APIs](/en/plugin/dev/apis/agent-run). diff --git a/en/plugin/dev/apis/platform.mdx b/en/plugin/dev/apis/platform.mdx new file mode 100644 index 000000000..d9edab3ec --- /dev/null +++ b/en/plugin/dev/apis/platform.mdx @@ -0,0 +1,78 @@ +--- +title: "Platform APIs" +--- + +Call platform adapter APIs using a bot UUID. Continue using `self.plugin.send_message()` for message chains; use the API below for groups, users, and other platform features. + +## Call a platform API + +```python +async def call_platform_api( + self, + bot_uuid: str, + action: str, + params: dict[str, Any] | None = None, +) -> Any: + ... +``` + +Call through `self.plugin` in Command, EventListener, Tool, and other regular components. Results are serialized dictionaries, lists, scalars, or `None`, depending on the action, rather than platform SDK objects. + +```python +group = await self.plugin.call_platform_api( + bot_uuid=bot_uuid, + action="get_group_info", + params={"group_id": "123456"}, +) +``` + +`bot_uuid` comes from `await self.plugin.get_bots()` or `await event_context.get_bot_uuid()` in a Pipeline event. Use platform IDs from real events or platform configuration. + +### Common actions + +| `action` | `params` | +| --- | --- | +| `get_user_info` | `user_id` | +| `get_friend_list` | `{}` | +| `get_group_info` | `group_id` | +| `get_group_list` | `{}` | +| `get_group_member_list` | `group_id` | +| `get_group_member_info` | `group_id`, `user_id` | +| `get_message` / `delete_message` | `chat_type`, `chat_id`, `message_id` | +| `set_group_name` | `group_id`, `name` | +| `mute_member` | `group_id`, `user_id`, `duration` | +| `unmute_member` / `kick_member` | `group_id`, `user_id` | +| `leave_group` | `group_id` | +| `approve_friend_request` | `request_id`, `approve`, `remark` | +| `approve_group_invite` | `request_id`, `approve` | +| `get_file_url` | `file_id` | + + +Supported actions vary by platform and may require bot administrator permissions. `duration` is in seconds; the meaning of `0` is platform-specific, not a universal permanent mute. Queries may depend on platform caches. Calls raise an exception if the bot is not running, the action is unsupported, or parameters are invalid. + +## Platform-specific APIs + +Set the outer `action` to `call_platform_api` and put the platform action and parameters inside `params`. For example, read the bot account using OneBot Omni: + +```python +account = await self.plugin.call_platform_api( + bot_uuid=bot_uuid, + action="call_platform_api", + params={"action": "get_login_info", "params": {}}, +) +``` + +Only actions declared by the adapter are allowed; this is not unrestricted pass-through to every platform endpoint. Plugins call the Host through the runtime, rather than accessing `ctx.query.adapter` or `adapter.bot`. + +## Calling from an event processor + +EventProcessor and AgentRunner use run-authorized tools and do not expose the generic `call_platform_api` method. For example, when the event supports actor lookup and grants it: + +```python +tools = {tool.tool_name for tool in ctx.api.get_allowed_tools()} +if "event_get_actor" in tools: + actor = await ctx.api.call_tool("event_get_actor", {}) + await ctx.log(str(actor)) +``` + +See [Run Context APIs](/en/plugin/dev/apis/agent-run) for access, permissions, and replies. Event debugging mocks platform tools; `self.plugin.call_platform_api()` in regular components performs real calls. diff --git a/en/plugin/dev/components/event-processor.mdx b/en/plugin/dev/components/event-processor.mdx index 1dec59c98..135187dcf 100644 --- a/en/plugin/dev/components/event-processor.mdx +++ b/en/plugin/dev/components/event-processor.mdx @@ -114,6 +114,20 @@ class Welcome(EventProcessor): `ctx.event` contains the event data, and `ctx.config` contains this processor's settings. Use `ctx.reply()` to reply and `ctx.log()` to write logs. Processing finishes when the handlers return. +## Streaming Replies + +Use `ctx.reply_stream()` to update one reply. `update(text)` takes the complete text so far: + +```python +async with ctx.reply_stream() as reply: + await reply.update("Working…") + await reply.update("Done.") +``` + +The reply finishes when the `async with` block exits normally. If streaming is unsupported, disabled, or the event is not a message, the Host sends one complete message at the end. Exceptions discard buffered partial text. Debug runs simulate delivery. + +This API uses the same permission as `ctx.reply()`. AgentRunner components can use `self.get_run_api(ctx).reply_stream()`; model output text is still not sent automatically. + ## Registering Events Register a handler with `@self.handler(EventClass)` and add the event identifier to `spec.events`. For example, `MemberJoinedEvent` corresponds to `group.member_joined`. @@ -156,3 +170,5 @@ After installing the plugin, bind the Bot's **Member joined** and **Message rece - Explore more examples in [EventProcessorDemo](https://github.com/langbot-app/langbot-plugin-demo/tree/main/EventProcessorDemo). - To extend Pipeline steps such as preprocessing or completion of a model call, use [Event Listener](/en/plugin/dev/components/event-listener). + +See [Run Context APIs](/en/plugin/dev/apis/agent-run) for replies, models, tools, history, and state. diff --git a/ja/plugin/dev/apis/agent-run.mdx b/ja/plugin/dev/apis/agent-run.mdx new file mode 100644 index 000000000..6738a54e6 --- /dev/null +++ b/ja/plugin/dev/apis/agent-run.mdx @@ -0,0 +1,182 @@ +--- +title: "実行コンテキスト API" +--- + +AgentRunner と EventProcessor は、現在の実行に許可されたモデル、ツール、ナレッジベース、コンテキストへアクセスする API を使用します。両コンポーネントでは `self.plugin` を使用できません。 + +## アクセス方法 + +```python +# Inside an EventProcessor handler +api = ctx.api + +# Inside AgentRunner.run(ctx) +api = self.get_run_api(ctx) +``` + +以下の `api` は現在の実行の API です。 同期の `get_allowed_*` とストリーミングのコンテキスト・イテレーター以外は、`await` で呼び出します。別の実行へ持ち越して再利用しないでください。 + +### 設定と権限 + +`ctx.config` はプロセッサーまたは Runner インスタンスの設定、`self.get_plugin_config()` はプラグイン全体の設定です。`spec.permissions` は要求する権限を宣言し、実際に利用できるリソースはユーザー設定と現在のイベントにも依存します。 + +```yaml +spec: + capabilities: + tool_calling: true + permissions: + tools: [detail, call] +``` + +返信とツール呼び出しに必要な最小設定です。他の機能を使う場合は `spec.permissions` に追加します: + +| `spec.permissions` | 操作 | +| --- | --- | +| `models` | `invoke`, `stream`, `rerank`, `count_tokens` | +| `tools` | `detail`, `call` | +| `knowledge_bases` | `list`, `retrieve` | +| `history` | `page`, `search` | +| `events` | `get`, `page` | +| `storage` | `plugin`, `workspace` | +| `interactions` | `request` | + +実際に許可されたリソースを確認します: + +```python +models = api.get_allowed_models() # Each item has model_id and operations +tools = api.get_allowed_tools() # Each item has tool_name and parameters +knowledge_bases = api.get_allowed_knowledge_bases() # Each item has kb_id +``` + +コンテキスト API の利用可否は `api.ctx.context.available_apis` で確認できます。`False` の API は呼び出せません。マニフェストへの権限追加だけで有効になるわけではありません。 + +## 返信とログ + +| EventProcessor メソッド | 用途 | +| --- | --- | +| `await ctx.reply(text)` | 現在のイベントの会話へテキストを送信し、操作結果を返します。 | +| `await ctx.log(text, level="info")` | `debug`、`info`、`warning`、`error` のログを記録します。 | +| `ctx.reply_stream()` | 非同期コンテキストマネージャーで同じ返信を更新します。 | + +`ctx.reply(text)` は `await api.call_tool("event_reply", {"text": text})` と同じです。イベントに返信先があり、`event_reply` が許可されている必要があります。ログや独立した Agent の出力テキストは自動送信されません。 + +### ストリーミング返信 + +```python +async with api.reply_stream() as reply: + await reply.update("Processing…") + await reply.update("Processing complete.") +result = reply.result +``` + +EventProcessor では `ctx.reply_stream()` を使うと、操作の開始と結果も記録されます。 + +`update(text)` には差分ではなく**その時点の全文**を渡します。戻り値は操作結果です。正常終了時に返信を完了します。プラットフォームでストリーミング返信が有効なメッセージイベントでは表示を更新し、それ以外の返信可能なイベントでは正常終了時に全文を一度送信します。例外やキャンセルでは未送信のテキストを破棄しますが、表示済みの内容の撤回は保証されません。デバッグ時の送信は Mock です。 + +`available_apis.reply_stream` と `event_reply` 権限が必要です。モデル出力のストリーミングとプラットフォームへの送信は別の API です。 + +## モデル、ツール、ナレッジベース + +モデルの引数と使用量の形式は[共通 API](/ja/plugin/dev/apis/common)を参照してください。実行 API には次のメソッドもあります: + +| メソッド | 戻り値・用途 | +| --- | --- | +| `invoke_llm(llm_model_uuid, messages, ...)` | `Message` | +| `invoke_llm_with_usage(llm_model_uuid, messages, ...)` | `LLMInvokeResult` | +| `invoke_llm_stream(llm_model_uuid, messages, ...)` | `MessageChunk` の非同期イテレーター | +| `invoke_llm_stream_events(llm_model_uuid, messages, ...)` | `LLMStreamEvent` の非同期イテレーター | +| `count_tokens(llm_model_uuid, messages, funcs=None, extra_args=None, timeout=None)` | 入力トークン数(`int`) | +| `invoke_rerank(rerank_model_uuid, query, documents, top_k=None, extra_args=None, timeout=30.0)` | `index`、`relevance_score` を含むリスト | +| `get_tool_detail(tool_name)` | ツールの説明と引数 schema(`dict`) | +| `call_tool(tool_name, parameters)` | ツール結果(`dict`)。`session`、`query_id` は不要 | +| `retrieve_knowledge(kb_id, query_text, top_k=5, filters=None)` | 検索結果のリスト | + +4 種類のモデル呼び出しは `remove_think=None`、非ストリーミング呼び出しは `timeout=None` も受け取ります。タイムアウトは実行の残り時間に制限されます。リソース一覧が空の場合もあります。 + +```python +allowed = {tool.tool_name for tool in api.get_allowed_tools()} +if "event_get_actor" in allowed: + actor = await api.call_tool("event_get_actor", {}) +``` + +`event_*` ツールの対象は LangBot がイベントから固定し、`platform_*` ツールの対象は schema に従って指定します。許可された一覧の名前と引数を使い、引数なしのツールには `{}` を渡します。 + +## 履歴とイベント + +現在の入力には全履歴が含まれません。必要に応じて現在の実行範囲の記録を取得します。別の会話 ID を渡してアクセス範囲を広げることはできません。 + +| メソッド | 戻り値 | +| --- | --- | +| `get_prompt()` | 適用されたプロンプトのリスト。機能フラグは `prompt_get` | +| `history_page(conversation_id=None, before_cursor=None, after_cursor=None, limit=50, direction="backward", include_attachments=False)` | `HistoryPage` | +| `history_search(query, filters=None, top_k=10)` | `HistorySearchResult` | +| `event_get(event_id)` | `AgentEventRecord` | +| `event_page(conversation_id=None, event_types=None, before_cursor=None, limit=50)` | `EventPage` | +| `steering_pull(mode="all", limit=None)` | `SteeringPullResult`。実行中に追加された入力を取得 | + +```python +if api.ctx.context.available_apis.history_page: + page = await api.history_page(limit=20) + for item in page.items: + print(item.model_dump()) + if page.has_more and page.prev_cursor: + older = await api.history_page(before_cursor=page.prev_cursor, limit=20) +``` + +ページ結果は SDK オブジェクトです。辞書ではなく `.items`、`.has_more`、`.prev_cursor`、`.next_cursor` を使います。古い記録は `before_cursor`、新しい記録は `after_cursor` と `direction="forward"` で取得します。履歴検索はテキスト一致のみの場合があります。追加入力対応 Runner の `steering_pull` は `all`、`one`、`one-at-a-time` を受け取ります。 + +## 状態とストレージ + +| メソッド | 戻り値 | +| --- | --- | +| `state_get(scope, key)` | `{"value": ...}` | +| `state_set(scope, key, value)` | `{"success": ...}` | +| `state_delete(scope, key)` | `{"success": ...}` | +| `state_list(scope, prefix=None, limit=100)` | `{"keys": [...], "has_more": ...}` | + +状態には `available_apis.state` とホストが許可するスコープ(`conversation`、`actor`、`subject`、`runner`)が必要です。値は JSON 化可能にし、`external.` などのキープレフィックスを推奨します。LangBot が永続化し、API は辞書を返します。 + +```python +if api.ctx.context.available_apis.state: + await api.state_set("conversation", "external.last_topic", "setup") + value = await api.state_get("conversation", "external.last_topic") +``` + +プラグインおよびワークスペースのストレージは `set_plugin_storage`、`get_plugin_storage`、`get_plugin_storage_keys`、`delete_plugin_storage` と対応する `*_workspace_storage*` を使用します。引数は[共通 API](/ja/plugin/dev/apis/common)と同じで、値は `bytes` です。`api.ctx.resources.storage.plugin_storage` または `workspace_storage` の権限が必要です。 + +## 実行記録 + +以下は同名の `available_apis` フラグが必要で、許可された範囲の実行のみ操作できます。 + +| メソッド | 戻り値 | +| --- | --- | +| `run_get(run_id=None)` | `AgentRun` | +| `run_list(conversation_id=None, statuses=None, before_cursor=None, limit=50)` | `RunPage` | +| `run_events_page(run_id=None, before_cursor=None, after_cursor=None, limit=50, direction="forward")` | `RunEventPage` | +| `run_cancel(run_id=None, reason=None)` | `AgentRun` | +| `run_append_result(result)` | `AgentRunEvent` | +| `run_finalize(run_id=None, status=None, reason=None)` | `AgentRun` | + +`run_id=None` は現在の実行を指します。EventProcessor はハンドラーの完了時に自動終了するため、通常は記録の手動追加・終了は不要です。AgentRunner は `yield AgentRunResult` で結果を報告します。同じ結果を `run_append_result` で重複登録しないでください。 + +## 構造化インタラクション(AgentRunner) + +確認やフォーム入力には `yield AgentRunResult.interaction_requested(ctx.run_id, request)` を使用します。`request` は `InteractionRequest` または辞書で、必須フィールドは `interaction_id`、`title`、`fallback_text` です。`kind`(`form`、`confirmation`、`choice`)、`fields`、`actions`、`expires_at` は任意です。 + +`spec.capabilities.interactions: true` と `spec.permissions.interactions: [request]` の宣言、およびホストの許可が必要です。画面の対応状況は `ctx.delivery.interactions` で確認し、送信結果は `interaction.submitted` イベントとして返ります。これは Runner の結果イベントであり、`ctx.api` のメソッドではありません。通常の EventProcessor ハンドラーには直接公開されていません。 + +## エラー処理 + +```python +from langbot_plugin.api.proxies.agent_run import PermissionDeniedError +from langbot_plugin.api.entities.builtin.agent_runner.errors import AgentAPIException + +try: + result = await api.call_tool("event_get_actor", {}) +except PermissionDeniedError as exc: + await ctx.log(str(exc), level="warning") +except AgentAPIException as exc: + await ctx.log(f"{exc.error.code}: {exc.error.message}", level="error") +``` + +EventProcessor 内の例です。`PermissionDeniedError` はリソース・操作・機能の権限不足を示します。`AgentAPIException.error` は `code`、`message`、`retryable`、`details` を持ちます。送信のタイムアウト時は成功済みの場合もあるため、`retryable` だけで再送しないでください。 diff --git a/ja/plugin/dev/apis/common.mdx b/ja/plugin/dev/apis/common.mdx index 7eb56e3bd..d1e97ce22 100644 --- a/ja/plugin/dev/apis/common.mdx +++ b/ja/plugin/dev/apis/common.mdx @@ -4,6 +4,8 @@ title: "プラグイン共通API" LangBotは、プラグインがさまざまなLangBotモジュールを操作し、メッセージコンテキストを制御するための一連のAPIを提供します。 +`Command`、`EventListener`、`Tool` などの通常のコンポーネントは `self.plugin` を使用します。`AgentRunner` と `EventProcessor` は `self.plugin` ではなく[実行コンテキスト API](/ja/plugin/dev/apis/agent-run)を使用してください。プラットフォームの操作は[プラットフォーム API](/ja/plugin/dev/apis/platform)を参照してください。 + ## リクエストAPI 現在処理中のユーザーリクエスト(メッセージ)に対する操作です。`EventListener`と`Command`コンポーネントでのみ使用可能です。アクセス方法: @@ -105,12 +107,20 @@ knowledge_bases = await event_context.list_pipeline_knowledge_bases() 現在のリクエストに対応するパイプラインの `Local Agent` 設定にバインドされたナレッジベースのみを返します。現在のパイプラインにナレッジベースが設定されていない場合、または `Local Agent` を使用していない場合は、空のリストが返されます。 +### 新しい会話を開始 + +`await event_context.create_new_conversation()` は現在のパイプラインセッションが使用している会話を解除し、後続の処理で新しい会話を開始します。空の辞書を返し、プラットフォームのチャット履歴は削除しません。 + +### パイプラインのナレッジベースを検索 + +`await event_context.retrieve_knowledge(kb_id, query_text, top_k=5, filters=None)` は検索結果のリストを返します。`kb_id` は `list_pipeline_knowledge_bases()` で取得します。このリクエスト API ではパイプラインに設定されていないナレッジベースにアクセスできません。 + ## LangBot API -これらのAPIは、任意のプラグインコンポーネントで呼び出すことができます。アクセス方法: +これらのAPIは、通常のプラグインコンポーネントで呼び出すことができます。アクセス方法: - プラグインルートディレクトリの`main.py`内: `self`オブジェクトの内部メソッド。これらのAPIはすべて、プラグインクラスの親クラス`BasePlugin`によって提供されます。 -- 任意のプラグインコンポーネントクラス内: `self.plugin`オブジェクトの内部メソッド。 +- 通常のプラグインコンポーネントクラス内: `self.plugin`オブジェクトの内部メソッド。 ### プラグイン設定の取得 @@ -224,6 +234,7 @@ async def invoke_llm( messages: list[provider_message.Message], funcs: list[resource_tool.LLMTool] = [], extra_args: dict[str, Any] = {}, + timeout: float | None = None, ) -> provider_message.Message: """LLMモデルを呼び出す""" @@ -236,6 +247,45 @@ llm_message = await self.plugin.invoke_llm( ) ``` + +### モデルのストリーミングと使用量 + +| メソッド | 戻り値 | +| --- | --- | +| `invoke_llm_with_usage(llm_model_uuid, messages, funcs=[], extra_args={}, timeout=None)` | `LLMInvokeResult` | +| `invoke_llm_stream(llm_model_uuid, messages, funcs=[], extra_args={})` | `MessageChunk` の非同期イテレーター | +| `invoke_llm_stream_events(llm_model_uuid, messages, funcs=[], extra_args={})` | `LLMStreamEvent` の非同期イテレーター | + + +引数は `invoke_llm` と同じです。`timeout` は秒数で、非ストリーミング呼び出しの既定値は 120 秒です。トークン使用量が必要な場合は `invoke_llm_with_usage` を使用します: + +```python +from langbot_plugin.api.entities.builtin.provider.message import Message + +result = await self.plugin.invoke_llm_with_usage( + llm_model_uuid=model_uuid, + messages=[Message(role="user", content="Hello!")], +) +print(result.message.content) +if result.usage is not None: + print(result.usage.total_tokens) +``` + +`usage` は `None` の場合があり、`prompt_tokens`、`completion_tokens`、`total_tokens` も欠ける場合があります。未取得は使用量ゼロを意味しません。キャッシュなどの追加フィールドは保持されます。 + +```python +async for event in self.plugin.invoke_llm_stream_events( + llm_model_uuid=model_uuid, + messages=[Message(role="user", content="Hello!")], +): + if event.chunk is not None: + print(event.chunk.content) + if event.usage is not None: + print(event.usage.model_dump()) +``` + +`LLMStreamEvent` は任意の `chunk` と `usage` を持ち、最後が使用量のみの場合もあります。`invoke_llm_stream` はメッセージ・ツール呼び出しのチャンクのみ返します。`MessageChunk.content` は現在の断片、`all_content` は提供される場合に累積テキストです。累積テキストは置換し、差分だけ追加します。`is_final` のチャンクにもテキストが含まれる場合があります。モデルのストリーミングはプラットフォームに送信しません。送信方法は[実行コンテキスト API](/ja/plugin/dev/apis/agent-run)を参照してください。 + ### 利用可能な Parser の一覧取得 ホスト上で現在利用可能な Parser プラグインを列挙します。MIME タイプで絞り込むこともできます。 @@ -428,7 +478,7 @@ file_bytes = await self.plugin.get_config_file("key") ## ナレッジベース API -これらのAPIは任意のコンポーネントから`self.plugin`経由でアクセスでき、パイプライン制限なしにLangBotインスタンス内のすべてのナレッジベースの一覧取得と検索が可能です。 +これらのAPIは通常のコンポーネントから`self.plugin`経由でアクセスでき、パイプライン制限なしにLangBotインスタンス内のすべてのナレッジベースの一覧取得と検索が可能です。 ### すべてのナレッジベースの一覧取得 @@ -486,7 +536,7 @@ for entry in results: ``` -これらのAPIは`query_id`を必要とせず、任意のコンポーネント(Toolコンポーネントを含む)で使用できます。現在のパイプライン設定に制限されることなく、すべてのナレッジベースにアクセスできます。 +これらのAPIは`query_id`を必要とせず、通常のコンポーネント(Toolコンポーネントを含む)で使用できます。現在のパイプライン設定に制限されることなく、すべてのナレッジベースにアクセスできます。 ## RAG API @@ -676,3 +726,81 @@ async def get_knowledge_file_stream(self, storage_path: str) -> bytes: # 使用例 file_bytes = await self.plugin.get_knowledge_file_stream(context.file_object.storage_path) ``` + + +## その他のリソース API + +以下も `self.plugin` から呼び出します。 + +### リランクモデルの呼び出し + +```python +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 = 60.0, +) -> list[dict[str, Any]]: + ... + +scores = await self.plugin.invoke_rerank( + rerank_model_uuid=rerank_model_uuid, + query="How do I configure a bot?", + documents=["Bot configuration guide", "Plugin publishing guide"], + top_k=1, +) +``` + +結果には通常、元の文書の `index` と関連度 `relevance_score` が含まれます。 + +### パーサーの呼び出し + +```python +async def invoke_parser( + self, plugin_author: str, plugin_name: str, storage_path: str, + mime_type: str, filename: str, metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + ... + +parsers = await self.plugin.list_parsers(mime_type="application/pdf") +if parsers: + parser = parsers[0] + parsed = await self.plugin.invoke_parser( + plugin_author=parser["plugin_author"], + plugin_name=parser["plugin_name"], + storage_path=storage_path, + mime_type="application/pdf", + filename="guide.pdf", + ) +``` + +`storage_path` はナレッジ取り込み時の `context.file_object.storage_path` など、ホストの保存先です。プラグインのローカルパスではありません。結果の辞書には `text`、`sections`、`metadata` が含まれます。 + +### ベクトルレコード一覧 + +```python +async def vector_list( + self, collection_id: str, filters: dict[str, Any] | None = None, + limit: int = 20, offset: int = 0, +) -> dict[str, Any]: + ... + +page = await self.plugin.vector_list( + collection_id=collection_id, + filters={"file_id": "doc_001"}, + limit=20, + offset=0, +) +for item in page["items"]: + print(item["id"], item.get("metadata")) +``` + +`items` と `total` を返します。各項目は `id`、`document`、`metadata` を含み、`total` はストレージが取得可能な一致件数です。フィルターは上記のベクトル API と同じ形式です。 + +### プラグインとコマンド一覧 + +```python +manifests = await self.plugin.list_plugins_manifest() +commands = await self.plugin.list_commands() +``` + +いずれも引数なしで、ホストが提供するプラグインのマニフェスト一覧とコマンド一覧を返します。 diff --git a/ja/plugin/dev/apis/messages.mdx b/ja/plugin/dev/apis/messages.mdx index 130ca772d..f390db8b3 100644 --- a/ja/plugin/dev/apis/messages.mdx +++ b/ja/plugin/dev/apis/messages.mdx @@ -58,34 +58,8 @@ msg_chain = MessageChain([ ## その他のエンティティ -上記のエンティティに加えて、`Friend`や`Group`などのエンティティもあります。これらのエンティティは`langbot_plugin.api.entities.platform.entities`で定義されています。 +上記のエンティティに加えて、`Friend`や`Group`などのエンティティもあります。これらのエンティティは`langbot_plugin.api.entities.builtin.platform.entities`で定義されています。 -## 基盤プラットフォームAPIへのアクセス +## プラットフォーム API - - 4.xには適用されません。 - - -プラットフォームの違いを抽象化するために、LangBotはメッセージングプラットフォームAPIの上に抽象化レイヤーを提供しています。これには、上記で説明した内容と、`pkg/platform/sources`ディレクトリ内のさまざまなプラットフォームアダプターが含まれます。 ただし、プラットフォームの違いが大きいため、抽象化レイヤーのエンティティとAPIのカプセル化は完全ではありません。プラグインがプラットフォーム固有の機能にアクセスする必要がある場合は、次の手順に従って基盤APIにアクセスできます。 - - - ```python - # 例えばプラグイン内で - @handler(GroupMessageReceived) - async def _(self, ctx: EventContext): - # アダプターオブジェクトを取得 - adapter = ctx.query.adapter - ``` - - -| プラットフォーム | アダプター | アクセス方法 | 説明 | -| ------------- | --------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| OneBot v11 | aiocqhttp | adapter.bot | botオブジェクトは[aiocqhttp](https://github.com/nonebot/aiocqhttp) CQHttpオブジェクトに対応します。詳細な使用方法についてはaiocqhttpドキュメントを参照してください | -| QQ公式API | qqofficial | adapter.bot | botオブジェクトはlibs/qq_official_api以下のSDKオブジェクトです。詳細な使用方法については、libs/qq_official_api/api.pyの`QQOfficialClient`内の`send_group_text_msg`メソッドを参照してHTTPリクエストメソッドを確認し、具体的なインターフェースドキュメントについてはQQ公式APIドキュメントを参照してください | -| WeCom(企業微信) | wecom | adapter.bot | libs/wecom_api/api.pyの`WecomClient`内の`send_image`メソッドを参照してHTTPリクエストメソッドを確認し、具体的なインターフェースドキュメントについてはWeCom APIドキュメントを参照してください | -| WeChat公式アカウント | officialaccount | - | インターフェースドキュメントについてはWeChat公式アカウントAPIドキュメントを参照してください。認証情報はadapter.botから取得できます | -| Lark | lark | adapter.api_client | Lark SDKのAPI Clientオブジェクトです。詳細については[oapi-sdk-python](https://github.com/larksuite/oapi-sdk-python)を参照してください | -| DingTalk | dingtalk | - | DingTalk APIドキュメントを参照してください。認証情報はadapter.botから取得できます | -| Discord | discord | adapter.bot | botオブジェクトは[Rapptz/discord.py](https://github.com/Rapptz/discord.py) Discordオブジェクトです。詳細な使用方法についてはドキュメントを参照してください | -| Telegram | telegram | adapter.bot | botオブジェクトは[python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) Telegramオブジェクトです。詳細な使用方法についてはドキュメントを参照してください | -| Slack | slack | adapter.bot | botオブジェクトはlibs/slack_api/api.py内にあります。詳細な使用方法についてはそのコードとSlack SDKを参照してください | \ No newline at end of file +ユーザー取得、グループ管理、固有の操作には[プラットフォーム API](/ja/plugin/dev/apis/platform)を使用してください。EventProcessor と AgentRunner は[実行コンテキスト API](/ja/plugin/dev/apis/agent-run)から許可されたツールを呼び出します。 diff --git a/ja/plugin/dev/apis/platform.mdx b/ja/plugin/dev/apis/platform.mdx new file mode 100644 index 000000000..59e8f4d58 --- /dev/null +++ b/ja/plugin/dev/apis/platform.mdx @@ -0,0 +1,78 @@ +--- +title: "プラットフォーム API" +--- + +ボット UUID を指定してプラットフォームアダプターの API を呼び出します。メッセージチェーンの送信は `self.plugin.send_message()`、グループやユーザーの取得などは以下を使用します。 + +## プラットフォーム API の呼び出し + +```python +async def call_platform_api( + self, + bot_uuid: str, + action: str, + params: dict[str, Any] | None = None, +) -> Any: + ... +``` + +Command、EventListener、Tool などのコンポーネントでは `self.plugin` から呼び出します。戻り値は操作に応じた辞書、リスト、スカラー、`None` で、プラットフォーム SDK のオブジェクトではありません。 + +```python +group = await self.plugin.call_platform_api( + bot_uuid=bot_uuid, + action="get_group_info", + params={"group_id": "123456"}, +) +``` + +`bot_uuid` は `await self.plugin.get_bots()`、またはパイプラインイベントの `await event_context.get_bot_uuid()` で取得できます。プラットフォーム ID には実際のイベントや設定の値を使用します。 + +### 主な操作 + +| `action` | `params` | +| --- | --- | +| `get_user_info` | `user_id` | +| `get_friend_list` | `{}` | +| `get_group_info` | `group_id` | +| `get_group_list` | `{}` | +| `get_group_member_list` | `group_id` | +| `get_group_member_info` | `group_id`, `user_id` | +| `get_message` / `delete_message` | `chat_type`, `chat_id`, `message_id` | +| `set_group_name` | `group_id`, `name` | +| `mute_member` | `group_id`, `user_id`, `duration` | +| `unmute_member` / `kick_member` | `group_id`, `user_id` | +| `leave_group` | `group_id` | +| `approve_friend_request` | `request_id`, `approve`, `remark` | +| `approve_group_invite` | `request_id`, `approve` | +| `get_file_url` | `file_id` | + + +対応する操作はプラットフォームごとに異なり、ボットの管理者権限が必要な場合があります。`duration` は秒数で、`0` の意味はプラットフォームに依存します。共通の永久ミュート値ではありません。取得結果がキャッシュに依存することもあります。ボットが未起動、未対応の操作、引数不正の場合は例外になります。 + +## プラットフォーム固有の API + +外側の `action` を `call_platform_api` にし、`params` にプラットフォームの操作と引数を指定します。OneBot Omni でボットのアカウント情報を取得する例です: + +```python +account = await self.plugin.call_platform_api( + bot_uuid=bot_uuid, + action="call_platform_api", + params={"action": "get_login_info", "params": {}}, +) +``` + +アダプターが宣言した操作のみ呼び出せます。すべてのプラットフォーム API を任意に転送できるわけではありません。プラグインはランタイム経由でホストを呼び出し、`ctx.query.adapter` や `adapter.bot` には直接アクセスしません。 + +## イベントプロセッサーからの呼び出し + +EventProcessor と AgentRunner は実行に許可されたツールを使用し、汎用の `call_platform_api` は公開しません。現在のイベントで発起者の取得が許可されている場合の例です: + +```python +tools = {tool.tool_name for tool in ctx.api.get_allowed_tools()} +if "event_get_actor" in tools: + actor = await ctx.api.call_tool("event_get_actor", {}) + await ctx.log(str(actor)) +``` + +アクセス方法、権限、返信の例は[実行コンテキスト API](/ja/plugin/dev/apis/agent-run)を参照してください。イベントデバッグのプラットフォームツールは Mock ですが、通常のコンポーネントの `self.plugin.call_platform_api()` は実際の呼び出しです。 diff --git a/ja/plugin/dev/components/event-processor.mdx b/ja/plugin/dev/components/event-processor.mdx index b6df1487d..3abbd3c26 100644 --- a/ja/plugin/dev/components/event-processor.mdx +++ b/ja/plugin/dev/components/event-processor.mdx @@ -114,6 +114,20 @@ class Welcome(EventProcessor): `ctx.event` は現在のイベント、`ctx.config` はこのプロセッサーの設定です。返信には `ctx.reply()`、ログの記録には `ctx.log()` を使います。関数の実行が終わると、イベントの処理も終了します。 +## ストリーミング返信 + +`ctx.reply_stream()` で同じ返信を更新します。`update(text)` には、その時点の全文を渡します。 + +```python +async with ctx.reply_stream() as reply: + await reply.update("処理中…") + await reply.update("完了しました。") +``` + +`async with` ブロックが正常終了すると返信が完了します。ストリーミングが未対応・無効の場合やメッセージ以外のイベントでは、最後に全文を一度だけ送信します。例外時には、バッファ内の未完成テキストを送信しません。デバッグでは送信をシミュレーションします。 + +権限は `ctx.reply()` と共通です。AgentRunner も `self.get_run_api(ctx).reply_stream()` を利用できます。モデルの出力テキストが自動送信されることはありません。 + ## イベントの登録 `@self.handler(イベントクラス)` で処理関数を登録し、対応する識別子を `spec.events` に追加します。例えば `MemberJoinedEvent` は `group.member_joined` に対応します。 @@ -156,3 +170,5 @@ class Welcome(EventProcessor): - [EventProcessorDemo](https://github.com/langbot-app/langbot-plugin-demo/tree/main/EventProcessorDemo)で他の実装例を確認できます。 - パイプラインの前処理やモデル呼び出し完了時などに機能を追加する場合は、[イベントリスナー](/ja/plugin/dev/components/event-listener)を使用してください。 + +返信、モデル、ツール、履歴、状態は[実行コンテキスト API](/ja/plugin/dev/apis/agent-run)を参照してください。 diff --git a/scripts/README-event-processor-validation.md b/scripts/README-event-processor-validation.md index add5e9900..678a5dd1e 100644 --- a/scripts/README-event-processor-validation.md +++ b/scripts/README-event-processor-validation.md @@ -6,6 +6,11 @@ Run the example checks with the SDK used by your LangBot installation. ## Repeatable checks +The API references in `{zh,en,ja}/plugin/dev/apis/` are also checked against the +installed SDK. Run `python scripts/verify-plugin-api-examples.py` to execute +the examples, validate method arguments, and check translated code parity. +The Host transport is mocked; this does not verify external delivery. + With the matching SDK installed in the active Python environment: ```bash diff --git a/scripts/verify-event-processor-tutorial.py b/scripts/verify-event-processor-tutorial.py index 43f88b209..6cd10940a 100644 --- a/scripts/verify-event-processor-tutorial.py +++ b/scripts/verify-event-processor-tutorial.py @@ -27,6 +27,7 @@ def load_tutorial(): for block in re.findall(r"^```yaml\n(.*?)^```", source, re.M | re.S) if "kind: EventProcessor" in block ] + python = [block for block in python if "class Welcome(EventProcessor):" in block] assert len(python) == len(manifests) == 1, path examples.append((python[0], manifests[0])) assert examples[0] == examples[1] == examples[2], "Locale examples differ" diff --git a/scripts/verify-plugin-api-examples.py b/scripts/verify-plugin-api-examples.py new file mode 100644 index 000000000..8f6b618b4 --- /dev/null +++ b/scripts/verify-plugin-api-examples.py @@ -0,0 +1,207 @@ +"""Run the API documentation snippets with the installed SDK and a mock Host. + +Usage: python scripts/verify-plugin-api-examples.py +This checks SDK calls and result shapes, not external platform delivery. +""" + +from __future__ import annotations + +import asyncio +import ast +import inspect +import re +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock + +from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext +from langbot_plugin.api.entities.builtin.agent_runner.errors import ( + AgentAPIError, + AgentAPIException, +) +from langbot_plugin.api.proxies.agent_run import AgentRunAPIProxy +from langbot_plugin.api.proxies.langbot_api import LangBotAPIProxy +from langbot_plugin.api.entities.builtin.provider.message import Message + +ROOT = Path(__file__).resolve().parents[1] + + +class Host: + def __init__(self): + self.calls = [] + + async def call_action(self, action, data, timeout=15): + name = action.value + self.calls.append((name, data)) + responses = { + "invoke_llm": { + "message": {"role": "assistant", "content": "Hello!"}, + "usage": {"total_tokens": 8}, + }, + "invoke_rerank": {"results": [{"index": 0, "relevance_score": 0.9}]}, + "list_parsers": { + "parsers": [{"plugin_author": "demo", "plugin_name": "Parser"}] + }, + "invoke_parser": {"text": "guide", "sections": [], "metadata": {}}, + "vector_list": { + "items": [{"id": "chunk", "document": "guide", "metadata": {}}], + "total": 1, + }, + "list_plugins_manifest": {"plugins": []}, + "list_commands": {"commands": []}, + "call_platform_api": {"result": {"id": "123456", "name": "Example"}}, + "call_tool": {"result": {"id": "alice"}}, + "reply_stream": {"result": {"ok": True, "mock": True}}, + "history_page": { + "items": [], + "has_more": not data.get("before_cursor"), + "prev_cursor": "older", + "next_cursor": None, + }, + "state_set": {"success": True}, + "state_get": {"value": "setup"}, + } + if name not in responses: + raise AssertionError(f"Unexpected Host action: {name}") + return responses[name] + + async def call_action_generator(self, action, data, timeout=15): + assert action.value == "invoke_llm_stream" + yield {"chunk": {"role": "assistant", "content": "Hello", "is_final": False}} + yield {"chunk": {"role": "assistant", "content": "!", "is_final": True}} + yield {"usage": {"total_tokens": 8}} + + +def context(): + return AgentRunContext.model_validate( + { + "run_id": "docs-run", + "trigger": {"type": "user_message"}, + "event": { + "event_id": "docs-event", + "event_type": "message.received", + "source": "test", + }, + "input": {"content": "Hello"}, + "delivery": {"surface": "test"}, + "runtime": {}, + "resources": { + "models": [{"model_id": "model"}], + "tools": [ + {"tool_name": "event_get_actor"}, + {"tool_name": "event_reply"}, + ], + }, + "context": { + "available_apis": { + "history_page": True, + "state": True, + "reply_stream": True, + } + }, + } + ) + + +def snippets(locale, page): + source = (ROOT / locale / f"plugin/dev/apis/{page}.mdx").read_text() + if page == "common": + headings = { + "zh": ("### 模型流式输出与用量", "## 其他资源 API"), + "en": ("### Model streaming and usage", "## Additional resource APIs"), + "ja": ("### モデルのストリーミングと使用量", "## その他のリソース API"), + }[locale] + start = source.index(headings[0]) + end = source.index("\n### ", start + len(headings[0])) + source = source[start:end] + source[source.index(headings[1]) :] + return re.findall(r"^```python\n(.*?)^```", source, re.M | re.S) + + +async def execute(block, namespace): + # Examples are function bodies; signature reference blocks may include stubs. + function = "async def example():\n" + "\n".join( + " " + line for line in block.splitlines() + ) + tree = ast.parse(function) + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + owner = ast.unparse(node.func.value) + cls = ( + LangBotAPIProxy + if owner == "self.plugin" + else AgentRunAPIProxy + if owner == "api" + else None + ) + if cls is not None: + signature = inspect.signature(getattr(cls, node.func.attr)) + signature.bind( + None, + *([None] * len(node.args)), + **{kw.arg: None for kw in node.keywords}, + ) + exec(compile(tree, "plugin-api-doc-example", "exec"), namespace) + await namespace["example"]() + + +async def main(): + total = 0 + for page in ("common", "agent-run", "platform"): + blocks = snippets("zh", page) + for locale in ("en", "ja"): + assert blocks == snippets(locale, page), f"{page}: {locale} code differs" + for block in blocks: + host = Host() + api = AgentRunAPIProxy(context(), host) + ctx = SimpleNamespace(api=api, log=AsyncMock()) + namespace = { + "Any": Any, + "Message": Message, + "api": api, + "ctx": ctx, + "self": SimpleNamespace( + plugin=LangBotAPIProxy(host), get_run_api=lambda _: api + ), + "model_uuid": "model", + "rerank_model_uuid": "model", + "collection_id": "collection", + "storage_path": "files/guide.pdf", + "bot_uuid": "bot", + "print": lambda *args: None, + } + await execute(block, namespace) + if "reply.update" in block: + ops = [ + data["operation"] + for name, data in host.calls + if name == "reply_stream" + ] + assert ops == ["update", "update", "finish"], ops + if "older =" in block: + pages = [data for name, data in host.calls if name == "history_page"] + assert len(pages) == 2 and pages[1]["before_cursor"] == "older" + if "except PermissionDeniedError" in block: + # Exercise both documented error handlers with the real SDK proxy. + api._allowed_tool_names = frozenset() + await execute(block, namespace) + ctx.log.assert_awaited() + api._allowed_tool_names = frozenset({"event_get_actor"}) + host.call_action = AsyncMock( + side_effect=AgentAPIException( + AgentAPIError( + code="host.action_error", message="Failed", retryable=False + ) + ) + ) + await execute(block, namespace) + assert ctx.log.await_count == 2 + total += 1 + print( + f"{total} API examples passed; all three locales have identical code. Host delivery is mocked." + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sitemap-alternates.xml b/sitemap-alternates.xml index 44f351f32..7a81e086b 100644 --- a/sitemap-alternates.xml +++ b/sitemap-alternates.xml @@ -1932,6 +1932,27 @@ + + https://langbot.app/docs/en/plugin/dev/apis/agent-run + + + + + + + https://langbot.app/docs/zh/plugin/dev/apis/agent-run + + + + + + + https://langbot.app/docs/ja/plugin/dev/apis/agent-run + + + + + https://langbot.app/docs/en/plugin/dev/apis/common @@ -1995,6 +2016,27 @@ + + https://langbot.app/docs/en/plugin/dev/apis/platform + + + + + + + https://langbot.app/docs/zh/plugin/dev/apis/platform + + + + + + + https://langbot.app/docs/ja/plugin/dev/apis/platform + + + + + https://langbot.app/docs/en/plugin/dev/apis/tech-details diff --git a/tests/agent-static-build.test.mjs b/tests/agent-static-build.test.mjs index 9ee1c33f0..fd217fbbd 100644 --- a/tests/agent-static-build.test.mjs +++ b/tests/agent-static-build.test.mjs @@ -22,7 +22,7 @@ function links(node, result = []) { } test("every sitemap page has meaningful canonical Markdown and HTML discovery", async () => { - assert.equal(urls.length, 485); + assert.equal(urls.length, 491); for (const url of urls) { const route = relative(url); const text = await read(`${route}.md`); diff --git a/tests/fumapress-migration.test.mjs b/tests/fumapress-migration.test.mjs index a99888ae5..edc265a74 100644 --- a/tests/fumapress-migration.test.mjs +++ b/tests/fumapress-migration.test.mjs @@ -170,8 +170,8 @@ test("MDX compatibility normalization removes internal .html route suffixes", () test("canonical MDX and nested OpenAPI sources are discovered", async () => { const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8")); const pages = await collectMdxDocuments(root); - assert.equal(pages.length, 308); - assert.deepEqual(Object.fromEntries([...pages.reduce((acc, page) => acc.set(page.split("/", 1)[0], (acc.get(page.split("/", 1)[0]) ?? 0) + 1), new Map()).entries()]), { en: 98, zh: 112, ja: 98 }); + assert.equal(pages.length, 314); + assert.deepEqual(Object.fromEntries([...pages.reduce((acc, page) => acc.set(page.split("/", 1)[0], (acc.get(page.split("/", 1)[0]) ?? 0) + 1), new Map()).entries()]), { en: 100, zh: 114, ja: 100 }); assert.deepEqual(collectOpenApiSources(docs), [ { locale: "en", source: "openapi/service-api-en.json", directory: "en/api-reference" }, { locale: "zh", source: "openapi/service-api-zh.json", directory: "zh/api-reference" }, @@ -216,7 +216,7 @@ test("prebuild is deterministic and preserves local assets and SEO files", async const first = await prepareFumapress({ root, outRoot: temp }); const second = await prepareFumapress({ root, outRoot: temp }); assert.deepEqual(first, second); - assert.equal(first.documents, 308); + assert.equal(first.documents, 314); assert.equal(first.fallbackDefaults, 0); assert.equal(first.localeOnlyDocuments, 14); assert.equal(first.redirects, 103); diff --git a/tests/fumapress-static-build.test.mjs b/tests/fumapress-static-build.test.mjs index ee5bf234c..d7eb4e298 100644 --- a/tests/fumapress-static-build.test.mjs +++ b/tests/fumapress-static-build.test.mjs @@ -45,7 +45,7 @@ function staticHtmlForUrlPath(urlPath) { test("all canonical localized documents have static HTML", async () => { const documents = await collectMdxDocuments(root); - assert.equal(documents.length, 308); + assert.equal(documents.length, 314); const missing = []; for (const document of documents) { try { diff --git a/tests/locale-static-build.test.mjs b/tests/locale-static-build.test.mjs index 3cd370884..6ab2a9cf8 100644 --- a/tests/locale-static-build.test.mjs +++ b/tests/locale-static-build.test.mjs @@ -35,6 +35,6 @@ test("every localized HTML payload includes the preference client provider", asy } } for (const locale of ["en", "zh", "ja"]) await visit(path.join(root, locale)); - // 485 canonical content pages plus one framework 404 page per locale. - assert.equal(count, 488); + // 491 canonical content pages plus one framework 404 page per locale. + assert.equal(count, 494); }); diff --git a/zh/plugin/dev/apis/agent-run.mdx b/zh/plugin/dev/apis/agent-run.mdx new file mode 100644 index 000000000..3bc152a2d --- /dev/null +++ b/zh/plugin/dev/apis/agent-run.mdx @@ -0,0 +1,182 @@ +--- +title: "运行上下文 API" +--- + +AgentRunner 和 EventProcessor 使用当前运行的 API,访问已授权的模型、工具、知识库和上下文。这两个组件不提供 `self.plugin`。 + +## 访问方式 + +```python +# Inside an EventProcessor handler +api = ctx.api + +# Inside AgentRunner.run(ctx) +api = self.get_run_api(ctx) +``` + +以下示例中的 `api` 均指当前运行的 API。 除 `get_allowed_*` 同步方法和流式上下文/迭代接口外,下文方法均使用 `await` 调用。不要跨运行保存或复用这个对象。 + +### 配置与授权 + +`ctx.config` 是处理器或 Runner 实例的配置;`self.get_plugin_config()` 读取插件级配置。清单中的 `spec.permissions` 只声明需要的权限,实际可用资源还取决于用户配置和当前事件。 + +```yaml +spec: + capabilities: + tool_calling: true + permissions: + tools: [detail, call] +``` + +这是回复消息和调用工具所需的最小配置。需要其他能力时,在 `spec.permissions` 中按需添加: + +| `spec.permissions` | 可选操作 | +| --- | --- | +| `models` | `invoke`, `stream`, `rerank`, `count_tokens` | +| `tools` | `detail`, `call` | +| `knowledge_bases` | `list`, `retrieve` | +| `history` | `page`, `search` | +| `events` | `get`, `page` | +| `storage` | `plugin`, `workspace` | +| `interactions` | `request` | + +查询实际授权的资源: + +```python +models = api.get_allowed_models() # Each item has model_id and operations +tools = api.get_allowed_tools() # Each item has tool_name and parameters +knowledge_bases = api.get_allowed_knowledge_bases() # Each item has kb_id +``` + +上下文能力可通过 `api.ctx.context.available_apis` 检查。某项为 `False` 时不要调用对应接口;只在清单中声明权限不会自动启用它。 + +## 回复与日志 + +| EventProcessor 方法 | 用途 | +| --- | --- | +| `await ctx.reply(text)` | 向当前事件的会话发送文本,返回动作结果。 | +| `await ctx.log(text, level="info")` | 记录日志;级别为 `debug`、`info`、`warning`、`error`。 | +| `ctx.reply_stream()` | 以异步上下文管理器更新同一条回复。 | + +`ctx.reply(text)` 等价于 `await api.call_tool("event_reply", {"text": text})`。需要当前事件具有回复目标,并且 `event_reply` 已被授权。日志和独立 Agent 返回的文本不会自动发送到平台。 + +### 流式回复 + +```python +async with api.reply_stream() as reply: + await reply.update("Processing…") + await reply.update("Processing complete.") +result = reply.result +``` + +EventProcessor 也可使用 `ctx.reply_stream()`,它会同时记录动作的开始与结果。 + +`update(text)` 接收**当前完整文本**,不是新增片段;返回本次动作结果。正常退出时完成回复。支持流式且开启流式回复的消息事件会更新平台消息;其他可回复事件会缓存文本,在成功结束时发送一次。异常或取消时丢弃缓存中的未完成文本,已显示的内容不保证能撤回。调试模式仅模拟平台发送。 + +此接口需要 `available_apis.reply_stream` 和 `event_reply` 权限。模型的流式输出与平台的流式发送是两个接口,需要插件主动连接。 + +## 模型、工具与知识库 + +模型调用的参数和用量结构见[通用 API](/zh/plugin/dev/apis/common)。运行 API 还提供以下方法: + +| 方法 | 返回或用途 | +| --- | --- | +| `invoke_llm(llm_model_uuid, messages, ...)` | `Message` | +| `invoke_llm_with_usage(llm_model_uuid, messages, ...)` | `LLMInvokeResult` | +| `invoke_llm_stream(llm_model_uuid, messages, ...)` | 异步迭代 `MessageChunk` | +| `invoke_llm_stream_events(llm_model_uuid, messages, ...)` | 异步迭代 `LLMStreamEvent` | +| `count_tokens(llm_model_uuid, messages, funcs=None, extra_args=None, timeout=None)` | 模型输入 token 数,`int` | +| `invoke_rerank(rerank_model_uuid, query, documents, top_k=None, extra_args=None, timeout=30.0)` | 带 `index`、`relevance_score` 的列表 | +| `get_tool_detail(tool_name)` | 工具说明与参数 schema,`dict` | +| `call_tool(tool_name, parameters)` | 工具结果,`dict`;无需 `session`、`query_id` | +| `retrieve_knowledge(kb_id, query_text, top_k=5, filters=None)` | 检索结果列表 | + +四个模型调用方法还支持 `remove_think=None`;非流式调用支持 `timeout=None`。调用超时受本次运行的剩余时间约束。资源列表可能为空,不能直接假定存在第一个模型或知识库。 + +```python +allowed = {tool.tool_name for tool in api.get_allowed_tools()} +if "event_get_actor" in allowed: + actor = await api.call_tool("event_get_actor", {}) +``` + +`event_*` 工具由 LangBot 固定事件中的目标,`platform_*` 工具允许按 schema 指定目标。使用实际授权列表中的名称与参数;无参数工具传 `{}`。 + +## 历史与事件 + +当前输入不包含完整历史。按需读取当前运行范围内的记录;不能通过传入其他会话 ID 扩大访问范围。 + +| 方法 | 返回值 | +| --- | --- | +| `get_prompt()` | 生效的提示词列表;能力标识为 `prompt_get` | +| `history_page(conversation_id=None, before_cursor=None, after_cursor=None, limit=50, direction="backward", include_attachments=False)` | `HistoryPage` | +| `history_search(query, filters=None, top_k=10)` | `HistorySearchResult` | +| `event_get(event_id)` | `AgentEventRecord` | +| `event_page(conversation_id=None, event_types=None, before_cursor=None, limit=50)` | `EventPage` | +| `steering_pull(mode="all", limit=None)` | `SteeringPullResult`;读取运行中排队的追加输入 | + +```python +if api.ctx.context.available_apis.history_page: + page = await api.history_page(limit=20) + for item in page.items: + print(item.model_dump()) + if page.has_more and page.prev_cursor: + older = await api.history_page(before_cursor=page.prev_cursor, limit=20) +``` + +分页结果是 SDK 对象,使用 `.items`、`.has_more`、`.prev_cursor`、`.next_cursor`,不是字典下标。读取旧记录使用 `before_cursor`;读取新记录使用 `after_cursor` 和 `direction="forward"`。历史搜索可能只提供文本匹配。`steering_pull` 的 `mode` 可为 `all`、`one` 或 `one-at-a-time`,用于支持追加输入的 Runner。 + +## 状态与存储 + +| 方法 | 返回值 | +| --- | --- | +| `state_get(scope, key)` | `{"value": ...}` | +| `state_set(scope, key, value)` | `{"success": ...}` | +| `state_delete(scope, key)` | `{"success": ...}` | +| `state_list(scope, prefix=None, limit=100)` | `{"keys": [...], "has_more": ...}` | + +状态需要 `available_apis.state` 和宿主允许的作用域:`conversation`(当前会话)、`actor`(当前用户)、`subject`(当前群组或对象)、`runner`(Runner 实例)。值必须可 JSON 序列化,建议使用 `external.` 等键前缀。状态由 LangBot 持久化,接口返回字典。 + +```python +if api.ctx.context.available_apis.state: + await api.state_set("conversation", "external.last_topic", "setup") + value = await api.state_get("conversation", "external.last_topic") +``` + +插件和工作空间存储使用 `set_plugin_storage`、`get_plugin_storage`、`get_plugin_storage_keys`、`delete_plugin_storage`,以及对应的 `*_workspace_storage*` 方法。参数与[通用 API](/zh/plugin/dev/apis/common)一致,值为 `bytes`,分别受 `api.ctx.resources.storage.plugin_storage` 和 `workspace_storage` 授权限制。 + +## 运行记录 + +以下接口按同名 `available_apis` 标识检查能力,只能操作当前授权范围内的运行。 + +| 方法 | 返回值 | +| --- | --- | +| `run_get(run_id=None)` | `AgentRun` | +| `run_list(conversation_id=None, statuses=None, before_cursor=None, limit=50)` | `RunPage` | +| `run_events_page(run_id=None, before_cursor=None, after_cursor=None, limit=50, direction="forward")` | `RunEventPage` | +| `run_cancel(run_id=None, reason=None)` | `AgentRun` | +| `run_append_result(result)` | `AgentRunEvent` | +| `run_finalize(run_id=None, status=None, reason=None)` | `AgentRun` | + +`run_id=None` 默认当前运行。普通 EventProcessor 处理函数返回后会自动结束,通常不需要手动追加或结束运行记录。AgentRunner 通过 `yield AgentRunResult` 上报结果,避免同时用 `run_append_result` 重复记录相同结果。 + +## 结构化交互(AgentRunner) + +需要用户确认或填写表单时,通过 `yield AgentRunResult.interaction_requested(ctx.run_id, request)` 请求交互。`request` 可以是 `InteractionRequest` 对象或字典,必填字段为 `interaction_id`、`title`、`fallback_text`;可选字段有 `kind`(`form`、`confirmation`、`choice`)、`fields`、`actions`、`expires_at`。 + +清单需声明 `spec.capabilities.interactions: true` 和 `spec.permissions.interactions: [request]`,并获得宿主授权。界面支持情况见 `ctx.delivery.interactions`;提交结果以 `interaction.submitted` 事件返回。这是 Runner 的结果事件,不是 `ctx.api` 方法,EventProcessor 的普通处理函数不直接提供此接口。 + +## 错误处理 + +```python +from langbot_plugin.api.proxies.agent_run import PermissionDeniedError +from langbot_plugin.api.entities.builtin.agent_runner.errors import AgentAPIException + +try: + result = await api.call_tool("event_get_actor", {}) +except PermissionDeniedError as exc: + await ctx.log(str(exc), level="warning") +except AgentAPIException as exc: + await ctx.log(f"{exc.error.code}: {exc.error.message}", level="error") +``` + +这是 EventProcessor 中的示例。`PermissionDeniedError` 表示资源、操作或能力未授权;`AgentAPIException.error` 包含 `code`、`message`、`retryable`、`details`。发送消息等动作超时后,结果可能不确定,不应仅依据 `retryable` 自动重发。 diff --git a/zh/plugin/dev/apis/common.mdx b/zh/plugin/dev/apis/common.mdx index 71dd51fa1..d555c4fb2 100644 --- a/zh/plugin/dev/apis/common.mdx +++ b/zh/plugin/dev/apis/common.mdx @@ -4,6 +4,8 @@ title: "插件通用 API" LangBot 为插件提供了一系列 API ,用于操作 LangBot 各个模块、控制消息上下文等。 +`Command`、`EventListener`、`Tool` 等普通组件通过 `self.plugin` 使用本页 API。`AgentRunner` 和 `EventProcessor` 请使用[运行上下文 API](/zh/plugin/dev/apis/agent-run),不使用 `self.plugin`。平台查询和专有动作见[平台 API](/zh/plugin/dev/apis/platform)。 + ## 请求 API 操作当前正在处理的用户请求(消息)。仅在`EventListener`和`Command`组件中可用。访问方式如下: @@ -105,12 +107,20 @@ knowledge_bases = await event_context.list_pipeline_knowledge_bases() 仅返回当前请求对应流水线在 `Local Agent` 配置中绑定的知识库。若当前流水线未配置知识库,或未使用 `Local Agent`,则返回空列表。 +### 开始新对话 + +`await event_context.create_new_conversation()` 清除当前流水线会话正在使用的对话,后续处理会重新建立对话。返回空字典,不会清除平台聊天记录。 + +### 检索当前流水线的知识库 + +`await event_context.retrieve_knowledge(kb_id, query_text, top_k=5, filters=None)` 返回检索结果列表。`kb_id` 必须来自 `list_pipeline_knowledge_bases()`;不能用这个请求接口访问流水线未配置的知识库。 + ## LangBot API -这些 API 可在插件任何组件中调用。访问方式如下: +这些 API 可在提供 `self.plugin` 的普通组件中调用。访问方式如下: - 插件根目录`main.py`中:`self` 对象内部方法,这些 API 均由插件类父类 `BasePlugin` 提供。 -- 插件任何组件类中:`self.plugin` 对象内部方法。 +- 普通组件类中:`self.plugin` 对象内部方法。 ### 获取插件配置信息 @@ -224,6 +234,7 @@ async def invoke_llm( messages: list[provider_message.Message], funcs: list[resource_tool.LLMTool] = [], extra_args: dict[str, Any] = {}, + timeout: float | None = None, ) -> provider_message.Message: """Invoke an LLM model""" @@ -236,6 +247,45 @@ llm_message = await self.plugin.invoke_llm( ) ``` + +### 模型流式输出与用量 + +| 方法 | 返回值 | +| --- | --- | +| `invoke_llm_with_usage(llm_model_uuid, messages, funcs=[], extra_args={}, timeout=None)` | `LLMInvokeResult` | +| `invoke_llm_stream(llm_model_uuid, messages, funcs=[], extra_args={})` | 异步迭代 `MessageChunk` | +| `invoke_llm_stream_events(llm_model_uuid, messages, funcs=[], extra_args={})` | 异步迭代 `LLMStreamEvent` | + + +参数与 `invoke_llm` 相同。`timeout` 单位为秒,非流式调用默认 120 秒。需要 token 用量时使用 `invoke_llm_with_usage`: + +```python +from langbot_plugin.api.entities.builtin.provider.message import Message + +result = await self.plugin.invoke_llm_with_usage( + llm_model_uuid=model_uuid, + messages=[Message(role="user", content="Hello!")], +) +print(result.message.content) +if result.usage is not None: + print(result.usage.total_tokens) +``` + +`usage` 可为 `None`,其中 `prompt_tokens`、`completion_tokens`、`total_tokens` 也可能缺失,不代表用量为零。供应商附加的缓存等用量字段会保留。 + +```python +async for event in self.plugin.invoke_llm_stream_events( + llm_model_uuid=model_uuid, + messages=[Message(role="user", content="Hello!")], +): + if event.chunk is not None: + print(event.chunk.content) + if event.usage is not None: + print(event.usage.model_dump()) +``` + +`LLMStreamEvent` 包含可选的 `chunk` 和 `usage`,末尾可能只有用量,没有文本。`invoke_llm_stream` 只返回文本或工具调用分块。`MessageChunk.content` 是当前片段,`all_content` 在提供时是累计文本;累计文本应用替换,增量文本才追加;`is_final` 为真时也可能仍有文本。模型流式调用不会直接发送平台消息,主动流式回复见[运行上下文 API](/zh/plugin/dev/apis/agent-run)。 + ### 列出可用解析器 列出宿主当前可用的 Parser 插件,可按 MIME 类型过滤。 @@ -428,7 +478,7 @@ file_bytes = await self.plugin.get_config_file("key") ## 知识库 API -这些 API 可在任意组件中通过 `self.plugin` 访问,用于列出和检索 LangBot 实例中的所有知识库,不受流水线配置限制。 +这些 API 可在提供 `self.plugin` 的普通组件中访问,用于列出和检索 LangBot 实例中的所有知识库,不受流水线配置限制。 ### 列出所有知识库 @@ -486,7 +536,7 @@ for entry in results: ``` -这些 API 不需要 `query_id`,可以在任何组件中使用(包括 Tool 组件),且可以访问所有知识库,不受当前流水线配置限制。 +这些 API 不需要 `query_id`,可以在普通组件中使用(包括 Tool 组件),且可以访问所有知识库,不受当前流水线配置限制。 ## RAG API @@ -676,3 +726,81 @@ async def get_knowledge_file_stream(self, storage_path: str) -> bytes: # 调用示例 file_bytes = await self.plugin.get_knowledge_file_stream(context.file_object.storage_path) ``` + + +## 其他资源 API + +以下方法同样通过 `self.plugin` 调用。 + +### 调用重排模型 + +```python +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 = 60.0, +) -> list[dict[str, Any]]: + ... + +scores = await self.plugin.invoke_rerank( + rerank_model_uuid=rerank_model_uuid, + query="How do I configure a bot?", + documents=["Bot configuration guide", "Plugin publishing guide"], + top_k=1, +) +``` + +返回结果通常包含原文档索引 `index` 和相关性分数 `relevance_score`。 + +### 调用解析器 + +```python +async def invoke_parser( + self, plugin_author: str, plugin_name: str, storage_path: str, + mime_type: str, filename: str, metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + ... + +parsers = await self.plugin.list_parsers(mime_type="application/pdf") +if parsers: + parser = parsers[0] + parsed = await self.plugin.invoke_parser( + plugin_author=parser["plugin_author"], + plugin_name=parser["plugin_name"], + storage_path=storage_path, + mime_type="application/pdf", + filename="guide.pdf", + ) +``` + +`storage_path` 是宿主存储中的文件路径,例如知识库摄取上下文的 `context.file_object.storage_path`,不是插件本地路径。返回字典包含 `text`、`sections`、`metadata`。 + +### 列出向量记录 + +```python +async def vector_list( + self, collection_id: str, filters: dict[str, Any] | None = None, + limit: int = 20, offset: int = 0, +) -> dict[str, Any]: + ... + +page = await self.plugin.vector_list( + collection_id=collection_id, + filters={"file_id": "doc_001"}, + limit=20, + offset=0, +) +for item in page["items"]: + print(item["id"], item.get("metadata")) +``` + +返回 `items` 和 `total`。条目包含 `id`、`document`、`metadata`;`total` 是存储后端尽力提供的匹配数量。过滤条件使用上文的向量 API 格式。 + +### 列出插件和命令 + +```python +manifests = await self.plugin.list_plugins_manifest() +commands = await self.plugin.list_commands() +``` + +两个方法均不需要参数,分别返回宿主提供的插件清单列表和命令列表。 diff --git a/zh/plugin/dev/apis/messages.mdx b/zh/plugin/dev/apis/messages.mdx index a5a5cee67..7ca749934 100644 --- a/zh/plugin/dev/apis/messages.mdx +++ b/zh/plugin/dev/apis/messages.mdx @@ -58,35 +58,8 @@ msg_chain = MessageChain([ ## 其他实体 -除了上述实体之外,还有`好友 Friend`和`群 Group`等实体,这些实体的定义位于`langbot_plugin.api.entities.platform.entities`中。 +除了上述实体之外,还有`好友 Friend`和`群 Group`等实体,这些实体的定义位于`langbot_plugin.api.entities.builtin.platform.entities`中。 -## 访问消息平台底层 API +## 平台 API - - 4.x 暂不适用。 - - -为了抹平各个平台的差异,LangBot 在消息平台API之上,提供了一个抽象层,即上方所述的内容,以及`pkg/platform/sources`目录下的各个平台适配器。\ -但由于各个平台的差异较大,抽象层内的实体和 API 封装并不完整,如果您的插件需要访问特定平台的功能,可以按照以下说明访问底层 API。 - - - ```python - # 例如在插件中 - @handler(GroupMessageReceived) - async def _(self, ctx: EventContext): - # 取得适配器对象 - adapter = ctx.query.adapter - ``` - - -| 平台 | 适配器 | 访问方式 | 说明 | -| ---------- | --------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| OneBot v11 | aiocqhttp | adapter.bot | bot 对象对应 [aiocqhttp](https://github.com/nonebot/aiocqhttp) 的 CQHttp 对象,详细使用方式请参考 aiocqhttp 文档 | -| QQ 官方API | qqofficial | adapter.bot | bot 对象为 libs/qq_official_api 下的 SDK 对象,详细使用方式可以参考 libs/qq_official_api/api.py 中`QQOfficialClient`中的`send_group_text_msg`方法发起 HTTP 请求的方式,具体接口文档请查看 QQ 官方 API 文档 | -| 企业微信 | wecom | adapter.bot | 参考 libs/wecom_api/api.py 中`WecomClient`中的`send_image`方法发起 HTTP 请求的方式,具体接口文档请查看企业微信 API 文档 | -| 微信公众号 | officialaccount | - | 请参考微信公众号的 API 文档中的接口文档信息,相关的凭据信息可以在 adapter.bot 中取得 | -| 飞书 | lark | adapter.api_client | 飞书 SDK 的 API Client 对象,具体请参考[oapi-sdk-python](https://github.com/larksuite/oapi-sdk-python) | -| 钉钉 | dingtalk | - | 请参考钉钉 API 文档,相关的凭据信息可以在 adapter.bot 中取得 | -| Discord | discord | adapter.bot | bot 对象为 [Rapptz/discord.py](https://github.com/Rapptz/discord.py) 的 Discord 对象,详细使用方式请参考文档 | -| Telegram | telegram | adapter.bot | bot 对象为 [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) 的 Telegram 对象,详细使用方式请参考文档 | -| Slack | slack | adapter.bot | bot 对象 libs/slack_api/api.py 中的对象,详细使用方式请参考其代码以及 Slack SDK | \ No newline at end of file +查询用户、管理群组和调用平台专有接口,请使用[平台 API](/zh/plugin/dev/apis/platform)。EventProcessor 和 AgentRunner 通过[运行上下文 API](/zh/plugin/dev/apis/agent-run)调用已授权的平台工具。 diff --git a/zh/plugin/dev/apis/platform.mdx b/zh/plugin/dev/apis/platform.mdx new file mode 100644 index 000000000..07fa809e7 --- /dev/null +++ b/zh/plugin/dev/apis/platform.mdx @@ -0,0 +1,78 @@ +--- +title: "平台 API" +--- + +通过机器人 UUID 调用平台适配器的 API。发送消息链可继续使用 `self.plugin.send_message()`;查询群组、用户及其他平台能力使用下面的接口。 + +## 调用平台 API + +```python +async def call_platform_api( + self, + bot_uuid: str, + action: str, + params: dict[str, Any] | None = None, +) -> Any: + ... +``` + +在 Command、EventListener、Tool 等组件中通过 `self.plugin` 调用。返回值为可序列化的字典、列表、标量或 `None`,具体取决于动作;不是平台 SDK 对象。 + +```python +group = await self.plugin.call_platform_api( + bot_uuid=bot_uuid, + action="get_group_info", + params={"group_id": "123456"}, +) +``` + +`bot_uuid` 可从 `await self.plugin.get_bots()` 获取,流水线事件中也可使用 `await event_context.get_bot_uuid()`。平台 ID 的值应来自实际事件或平台配置。 + +### 常用动作 + +| `action` | `params` | +| --- | --- | +| `get_user_info` | `user_id` | +| `get_friend_list` | `{}` | +| `get_group_info` | `group_id` | +| `get_group_list` | `{}` | +| `get_group_member_list` | `group_id` | +| `get_group_member_info` | `group_id`, `user_id` | +| `get_message` / `delete_message` | `chat_type`, `chat_id`, `message_id` | +| `set_group_name` | `group_id`, `name` | +| `mute_member` | `group_id`, `user_id`, `duration` | +| `unmute_member` / `kick_member` | `group_id`, `user_id` | +| `leave_group` | `group_id` | +| `approve_friend_request` | `request_id`, `approve`, `remark` | +| `approve_group_invite` | `request_id`, `approve` | +| `get_file_url` | `file_id` | + + +每个平台支持的动作不同,也可能要求机器人管理员权限。`duration` 单位为秒,`0` 的含义依平台而异,不应作为通用的永久禁言值。查询结果可能依赖平台缓存。机器人未运行、动作不受支持或参数不正确时,调用会抛出异常。 + +## 平台专有 API + +将外层 `action` 设为 `call_platform_api`,在 `params` 中填写平台动作及其参数。例如,使用 OneBot Omni 读取机器人账号信息: + +```python +account = await self.plugin.call_platform_api( + bot_uuid=bot_uuid, + action="call_platform_api", + params={"action": "get_login_info", "params": {}}, +) +``` + +只能调用适配器已声明的动作,并非任意平台接口都能透传。插件通过运行时调用宿主,不直接访问 `ctx.query.adapter` 或 `adapter.bot`。 + +## 在事件处理器中调用 + +EventProcessor 和 AgentRunner 使用运行授权的工具,不提供通用的 `call_platform_api` 方法。例如,在当前事件支持查询发起者且已授权时: + +```python +tools = {tool.tool_name for tool in ctx.api.get_allowed_tools()} +if "event_get_actor" in tools: + actor = await ctx.api.call_tool("event_get_actor", {}) + await ctx.log(str(actor)) +``` + +访问方式、权限与回复示例见[运行上下文 API](/zh/plugin/dev/apis/agent-run)。事件调试中的平台工具使用 Mock;普通组件的 `self.plugin.call_platform_api()` 是实际调用。 diff --git a/zh/plugin/dev/components/event-processor.mdx b/zh/plugin/dev/components/event-processor.mdx index 9c8c51516..990fc241f 100644 --- a/zh/plugin/dev/components/event-processor.mdx +++ b/zh/plugin/dev/components/event-processor.mdx @@ -114,6 +114,20 @@ class Welcome(EventProcessor): 处理函数中,`ctx.event` 保存当前事件的数据,`ctx.config` 是当前处理器的配置。调用 `ctx.reply()` 回复消息,`ctx.log()` 记录日志。函数执行完毕,本次处理即结束。 +## 流式回复 + +使用 `ctx.reply_stream()` 更新同一条回复,`update(text)` 接收当前的完整文本: + +```python +async with ctx.reply_stream() as reply: + await reply.update("正在处理…") + await reply.update("处理完成。") +``` + +正常退出 `async with` 时完成回复。平台不支持流式、未开启流式回复或当前不是消息事件时,会在结束后发送一条完整消息;发生异常时不发送缓存中的未完成文本。调试模式仅模拟发送。 + +此接口沿用 `ctx.reply()` 的权限。AgentRunner 可通过 `self.get_run_api(ctx).reply_stream()` 使用同一接口,模型返回文本仍不会自动发送。 + ## 事件注册 通过 `@self.handler(事件类)` 注册处理函数,并将对应事件标识加入清单的 `spec.events`。例如,`MemberJoinedEvent` 对应 `group.member_joined`。 @@ -156,3 +170,5 @@ class Welcome(EventProcessor): - 查看 [EventProcessorDemo](https://github.com/langbot-app/langbot-plugin-demo/tree/main/EventProcessorDemo) 中的更多示例。 - 如果要扩展流水线的预处理、模型调用完成等步骤,请使用[事件监听器](/zh/plugin/dev/components/event-listener)。 + +回复、模型、工具、历史与状态接口见[运行上下文 API](/zh/plugin/dev/apis/agent-run)。